feat: minihra Tanky
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m57s
CI / Build and push Docker image (push) Successful in 46s
CI / Notify (push) Successful in 2s

Nová skrytá minihra (kód "tanky") pro více hráčů, simulaci drží server:
- jízda šipkami/WSAD, otočná věž míří za myší, střelba klikem nebo mezerníkem
- režimy poslední přeživší (výchozí) a na čas, volitelně ve dvou týmech
- volitelné odrážení střel od zdí (výchozí vypnuto)
- hardcore režim: tma, vidět je jen světlo kolem tanku a reflektor ve směru věže
- bonusy: štít, trojitá střela, rakety, turbo, neviditelnost, naváděné střely, miny, život
- 7 map v pěti prostředích (město, les, poušť, zima, bažina) s vodou,
  zpomalujícím terénem a zničitelnými bednami
- boti se třemi obtížnostmi (hledání cesty, rozstřelování beden, v hardcore vidí jen světlo)
- efekty: záblesk výstřelu, výbuchy, hořící vraky

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hrubyjarandClaude Opus 5.5 committed 2026-10-20 15:58:47 +02:00
1 parent d2125a050b
commit 96dc3f0053
10 files changed
+3413 -12

No files matched your search

+425
View File
@@ -0,0 +1,425 @@
import {
TankGame, TankState, MAPS, COLS, ROWS, TICK_MS, COUNTDOWN_SECONDS, RESPAWN_MS, DEATHMATCH_MS,
LAST_MAN_LIVES, MAX_PLAYERS, TANK_RADIUS, ROCKET_AMMO, MINE_AMMO, MINE_ARM_MS,
} from '../tankGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
/** Deterministická pseudonáhoda pro boty. */
function lcg(seed = 1) {
let x = seed;
return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648;
}
function setup(random?: () => number) {
const clock = fakeClock(random);
const states: TankState[] = [];
const game = new TankGame(s => states.push(s), s => states.push(s), 2, clock);
const last = () => states[states.length - 1];
const player = (login: string) => last().players.find(p => p.login === login)!;
/** Odrážení je ve výchozím stavu vypnuté, testy ho zapínají — sebezničení odraženou střelou je nejjednodušší zásah. */
const startGame = (mode: 'deathmatch' | 'lastman' = 'deathmatch', all = ['alice', 'bob'], map = 'arena', teams = false, bounce = true) => {
all.forEach(l => game.join(l));
game.configure(all[0], { mode, mapId: map, teams, bounce });
game.start(all[0]);
clock.advance(COUNTDOWN_SECONDS * 1000);
};
const ticks = (n: number) => clock.advance(n * TICK_MS);
/** Jeden výstřel: stisk na jeden tick a puštění. */
const shoot = (login: string) => {
game.input(login, { fire: true });
ticks(1);
game.input(login, { fire: false });
};
return { game, clock, last, player, startGame, ticks, shoot };
}
describe('tanky', () => {
test('mapy mají správný rozměr, dost spawnů a všechna volná místa jsou propojená', () => {
for (const map of MAPS) {
expect(map.rows).toHaveLength(ROWS);
map.rows.forEach(r => expect(r).toHaveLength(COLS));
map.rows.forEach(r => expect(r).toMatch(/^[.#+~:%S]+$/));
// Každý tým má na své půlce dost spawnů
const leftSpawns = map.rows.join('').split('').filter((c, i) => c === 'S' && i % COLS < COLS / 2).length;
expect(leftSpawns).toBeGreaterThanOrEqual(MAX_PLAYERS / 2);
// Bedny se dají rozstřílet, takže se počítají jako průjezdné; zeď a voda ne
const blocked = (c: string) => c === '#' || c === '~';
const open: [number, number][] = [];
map.rows.forEach((r, y) => [...r].forEach((c, x) => { if (!blocked(c)) open.push([x, y]); }));
const seen = new Set([open[0].join()]);
const queue = [open[0]];
while (queue.length) {
const [x, y] = queue.shift()!;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (map.rows[ny]?.[nx] === undefined || blocked(map.rows[ny][nx]) || seen.has(`${nx},${ny}`)) continue;
seen.add(`${nx},${ny}`);
queue.push([nx, ny]);
}
}
expect(seen.size).toBe(open.length);
}
});
test('hru spouští zakladatel a potřebuje alespoň dva tanky (boti se počítají)', () => {
const ctx = setup();
ctx.game.join('alice');
expect(() => ctx.game.start('alice')).toThrow(GameError);
expect(() => ctx.game.addBot('bob', 'easy')).toThrow(GameError);
ctx.game.addBot('alice', 'easy');
expect(ctx.last().players.map(p => p.login)).toContain('🤖 Bot 1 (Lehký)');
ctx.game.start('alice');
expect(ctx.last().phase).toBe('countdown');
expect(() => ctx.game.join('bob')).toThrow(GameError);
});
test('tank jede kupředu a zastaví se o okraj hřiště', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 100, 60, Math.PI);
ctx.game.input('alice', { drive: 1 });
ctx.ticks(30);
expect(ctx.player('alice').x).toBe(TANK_RADIUS);
expect(ctx.player('alice').y).toBe(60);
});
test('nesmyslné ovládání se ořízne', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 300, 60, 0);
ctx.game.input('alice', { drive: 5, turn: 'x' });
ctx.ticks(10);
expect(ctx.player('alice').x).toBeGreaterThan(300);
expect(ctx.player('alice').angle).toBe(0);
});
test('zásah zničí soupeře, přidá bod a soupeř se po chvíli znovu objeví', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 100, 60, 0);
ctx.game.placeForTest('bob', 300, 60, 0);
ctx.shoot('alice');
ctx.ticks(30);
expect(ctx.player('alice')).toMatchObject({ score: 1, kills: 1 });
expect(ctx.player('bob')).toMatchObject({ alive: false, deaths: 1 });
ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1);
expect(ctx.player('bob')).toMatchObject({ alive: true, protected: true });
});
test('věž se natočí za myší nezávisle na korbě a střílí tím směrem', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 60, 60, 0);
ctx.game.placeForTest('bob', 60, 250, 0);
ctx.game.aimAt('alice', { x: 60, y: 250 });
ctx.ticks(10);
expect(ctx.player('alice').turret).toBeCloseTo(Math.PI / 2, 1);
expect(ctx.player('alice').angle).toBe(0);
ctx.shoot('alice');
ctx.ticks(30);
expect(ctx.player('bob')).toMatchObject({ alive: false });
expect(ctx.player('alice').kills).toBe(1);
// Bez myši se věž vrátí do směru jízdy
ctx.game.aimAt('alice', { x: null });
ctx.ticks(10);
expect(ctx.player('alice').turret).toBeCloseTo(0, 1);
});
test('ve výchozím stavu je režim poslední přeživší a střela na zdi zanikne', () => {
const ctx = setup();
ctx.game.join('alice');
ctx.game.join('bob');
expect(ctx.last()).toMatchObject({ mode: 'lastman', bounce: false });
ctx.game.configure('alice', { mapId: 'arena' });
ctx.game.start('alice');
ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2);
ctx.game.placeForTest('bob', 900, 500, 0);
ctx.shoot('alice');
ctx.ticks(20);
expect(ctx.player('alice')).toMatchObject({ alive: true, lives: LAST_MAN_LIVES });
expect(ctx.last().bullets).toHaveLength(0);
});
test('odražená vlastní střela zničí i střelce a stojí bod', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2);
ctx.shoot('alice');
ctx.ticks(20);
expect(ctx.player('alice')).toMatchObject({ alive: false, score: -1, kills: 0 });
});
test('střela rozbije bednu a zanikne', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 380, 20, 0);
ctx.shoot('alice');
ctx.ticks(10);
expect(ctx.last().tiles[0][11]).toBe('.');
expect(ctx.last().tiles[0][12]).toBe('+');
expect(ctx.last().bullets).toHaveLength(0);
});
test('výstřel s hlavní v bedně ji rozbije rovnou', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 425, 20, 0);
ctx.shoot('alice');
expect(ctx.last().tiles[0][11]).toBe('.');
expect(ctx.last().bullets).toHaveLength(0);
});
test('na přežití vypadne, kdo přijde o všechny životy, a hra skončí', () => {
const ctx = setup();
ctx.startGame('lastman');
expect(ctx.player('bob').lives).toBe(LAST_MAN_LIVES);
for (let i = 0; i < LAST_MAN_LIVES; i++) {
ctx.game.placeForTest('alice', 900, 300, 0);
ctx.game.placeForTest('bob', 100, 60, -Math.PI / 2);
ctx.shoot('bob');
ctx.ticks(20);
if (i < LAST_MAN_LIVES - 1) ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1);
}
expect(ctx.last().phase).toBe('finished');
expect(ctx.player('alice').place).toBe(1);
expect(ctx.player('bob')).toMatchObject({ place: 2, lives: 0 });
});
test('hra na čas skončí po vypršení limitu', () => {
const ctx = setup();
ctx.startGame();
ctx.clock.advance(DEATHMATCH_MS + 100);
expect(ctx.last().phase).toBe('finished');
expect(ctx.last().players.every(p => p.place !== undefined)).toBe(true);
});
test('boti hrají mezi sebou a navzájem se ničí', () => {
const ctx = setup(lcg(7));
ctx.game.join('alice');
ctx.game.configure('alice', { mode: 'deathmatch', mapId: 'maze' });
ctx.game.addBot('alice', 'hard');
ctx.game.addBot('alice', 'medium');
ctx.game.start('alice');
ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
ctx.clock.advance(60 * 1000);
const bots = ctx.last().players.filter(p => p.bot);
expect(bots.reduce((sum, b) => sum + b.kills, 0)).toBeGreaterThan(0);
});
});
describe('tanky — bonusy', () => {
/** Položí bonus pod tank a nechá ho sebrat. */
const pickUp = (ctx: ReturnType<typeof setup>, login: string, type: Parameters<TankGame['addPickupForTest']>[2]) => {
ctx.ticks(1); // aktuální poloha po placeForTest
const p = ctx.player(login);
ctx.game.addPickupForTest(p.x, p.y, type);
ctx.ticks(1);
};
test('raketa vybuchne při zásahu, zničí tank a zbyde po něm hořící vrak', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 100, 60, 0);
ctx.game.placeForTest('bob', 300, 60, 0);
pickUp(ctx, 'alice', 'rocket');
expect(ctx.player('alice').rockets).toBe(ROCKET_AMMO);
ctx.shoot('alice');
expect(ctx.last().bullets[0]).toMatchObject({ rocket: true });
ctx.ticks(30);
expect(ctx.player('alice')).toMatchObject({ rockets: ROCKET_AMMO - 1, kills: 1, shots: 1 });
expect(ctx.player('bob').alive).toBe(false);
expect(ctx.last().wrecks).toHaveLength(1);
});
test('výbuch rakety rozbije i sousední bedny', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 380, 20, 0);
ctx.game.placeForTest('bob', 900, 500, 0);
pickUp(ctx, 'alice', 'rocket');
ctx.shoot('alice');
ctx.ticks(10);
expect(ctx.last().tiles[0].slice(11, 13)).toBe('..');
expect(ctx.last().explosions.length).toBeGreaterThan(0);
});
test('mina se aktivuje až po chvíli a vybuchne pod soupeřem', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 100, 60, 0);
ctx.game.placeForTest('bob', 500, 300, 0);
pickUp(ctx, 'alice', 'mine');
ctx.game.dropMine('alice');
ctx.ticks(1);
expect(ctx.player('alice').mines).toBe(MINE_AMMO - 1);
expect(ctx.last().mines[0]).toMatchObject({ x: 80, y: 60, armed: false });
ctx.game.placeForTest('alice', 500, 500, 0);
ctx.ticks(Math.ceil(MINE_ARM_MS / TICK_MS));
ctx.game.placeForTest('bob', 200, 60, Math.PI);
ctx.game.input('bob', { drive: 1 });
ctx.ticks(40);
expect(ctx.player('bob').alive).toBe(false);
expect(ctx.player('alice').kills).toBe(1);
expect(ctx.last().mines).toHaveLength(0);
});
test('štít pohltí jeden zásah', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 100, 60, 0);
ctx.game.placeForTest('bob', 300, 60, 0);
pickUp(ctx, 'bob', 'shield');
ctx.shoot('alice');
ctx.ticks(30);
expect(ctx.player('bob')).toMatchObject({ alive: true, shield: false });
});
test('turbo zrychlí jízdu', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 100, 60, 0);
ctx.game.placeForTest('bob', 100, 300, 0);
pickUp(ctx, 'alice', 'turbo');
ctx.game.input('alice', { drive: 1 });
ctx.game.input('bob', { drive: 1 });
ctx.ticks(20);
expect(ctx.player('alice').turbo).toBe(true);
expect(ctx.player('alice').x - 100).toBeGreaterThan((ctx.player('bob').x - 100) * 1.4);
});
test('naváděná střela se stočí k soupeři', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 100, 60, 0);
ctx.game.placeForTest('bob', 300, 130, 0);
pickUp(ctx, 'alice', 'homing');
ctx.shoot('alice');
ctx.ticks(40);
expect(ctx.player('bob').alive).toBe(false);
});
test('život navíc se ve hře na přežití vrátí, ale nepřesáhne maximum', () => {
const ctx = setup();
ctx.startGame('lastman');
ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2);
ctx.shoot('alice');
ctx.ticks(20);
expect(ctx.player('alice').lives).toBe(LAST_MAN_LIVES - 1);
ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1);
pickUp(ctx, 'alice', 'heart');
pickUp(ctx, 'alice', 'heart');
expect(ctx.player('alice').lives).toBe(LAST_MAN_LIVES);
});
});
describe('tanky — terén', () => {
test('voda tank zastaví, ale střela ji přeletí', () => {
const ctx = setup();
// Les: řádek 7 (y 280–320) je řeka, v prvním sloupci bez brodu
ctx.startGame('deathmatch', ['alice', 'bob'], 'forest');
ctx.game.placeForTest('alice', 20, 240, Math.PI / 2);
ctx.game.placeForTest('bob', 20, 380, 0);
ctx.game.input('alice', { drive: 1 });
ctx.ticks(30);
expect(ctx.player('alice').y).toBeLessThanOrEqual(280 - TANK_RADIUS);
ctx.game.input('alice', { drive: 0 });
ctx.shoot('alice');
ctx.ticks(20);
expect(ctx.player('bob').alive).toBe(false);
});
test('porost zpomaluje jízdu', () => {
const ctx = setup();
// Les: v prvním řádku jsou sloupce 3–4 (x 120–200) porost, třetí řádek vlevo je volný
ctx.startGame('deathmatch', ['alice', 'bob'], 'forest');
ctx.game.placeForTest('alice', 130, 20, 0);
ctx.game.placeForTest('bob', 20, 100, 0);
ctx.game.input('alice', { drive: 1 });
ctx.game.input('bob', { drive: 1 });
ctx.ticks(10);
expect(ctx.player('alice').x - 130).toBeLessThan((ctx.player('bob').x - 20) * 0.7);
});
});
describe('tanky — hardcore (tma)', () => {
/** alice a těžký bot ve volném druhém řádku arény, bot natočený od alice. */
function duel(hardcore: boolean, distance: number) {
const ctx = setup();
ctx.game.join('alice');
ctx.game.configure('alice', { mode: 'deathmatch', mapId: 'arena', hardcore });
ctx.game.addBot('alice', 'hard');
ctx.game.start('alice');
ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
const bot = ctx.last().players.find(p => p.bot)!.login;
ctx.game.placeForTest('alice', 100, 60, 0);
ctx.game.placeForTest(bot, 100 + distance, 60, 0);
ctx.ticks(30);
return { ...ctx, bot };
}
test('zakladatel ho zapne v lobby', () => {
const ctx = setup();
ctx.game.join('alice');
ctx.game.configure('alice', { hardcore: true });
expect(ctx.last().hardcore).toBe(true);
});
test('bot ve tmě nevidí soupeře mimo světlo, bez tmy ano', () => {
expect(duel(true, 600).last().bullets).toHaveLength(0);
const lit = duel(false, 600);
expect(lit.last().bullets.length > 0 || !lit.player('alice').alive).toBe(true);
});
test('bot ve tmě vidí soupeře ve svém okolí', () => {
const ctx = duel(true, 110);
expect(ctx.last().bullets.length > 0 || !ctx.player('alice').alive).toBe(true);
});
});
describe('tanky — týmy', () => {
const LOGINS = ['alice', 'bob', 'carol', 'dave'];
test('hráči se střídavě rozdělí do týmů a jde se přesunout', () => {
const ctx = setup();
LOGINS.forEach(l => ctx.game.join(l));
ctx.game.configure('alice', { teams: true });
expect(ctx.last().players.map(p => p.team)).toEqual([0, 1, 0, 1]);
expect(() => ctx.game.switchTeam('bob', 'carol')).toThrow(GameError);
ctx.game.switchTeam('bob');
expect(ctx.player('bob').team).toBe(0);
ctx.game.switchTeam('alice', 'bob');
expect(ctx.player('bob').team).toBe(1);
});
test('týmy začínají každý na své půlce a střela spoluhráčem proletí', () => {
const ctx = setup();
ctx.startGame('deathmatch', LOGINS, 'arena', true);
for (const p of ctx.last().players) {
expect(p.x < 480).toBe(p.team === 0);
}
// alice a carol jsou tým 0, bob tým 1
ctx.game.placeForTest('alice', 100, 60, 0);
ctx.game.placeForTest('carol', 200, 60, 0);
ctx.game.placeForTest('bob', 300, 60, 0);
ctx.shoot('alice');
ctx.ticks(30);
expect(ctx.player('carol').alive).toBe(true);
expect(ctx.player('bob').alive).toBe(false);
expect(ctx.last().teamScores).toEqual([1, 0]);
});
test('na čas vyhrává tým s víc body', () => {
const ctx = setup();
ctx.startGame('deathmatch', LOGINS, 'arena', true);
ctx.game.placeForTest('bob', 100, 60, 0);
ctx.game.placeForTest('alice', 300, 60, 0);
ctx.shoot('bob');
ctx.clock.advance(DEATHMATCH_MS);
const state = ctx.last();
expect(state.phase).toBe('finished');
expect(state.winnerTeam).toBe(1);
expect(state.players.filter(p => p.place! <= 2).every(p => p.team === 1)).toBe(true);
});
});