| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- import { describe, expect, it } from 'vitest';
- import { loadCharacterStats, loadMap } from '../src/server/assets';
- import { Game } from '../src/server/game';
- import { Anim } from '../src/shared/characters';
- import { CHERRY_HOT_S, CHERRY_STEAL_BONUS, CRUSH_KILL_PIXELS, PIXELS_PER_METER, SURPRISE_DURATIONS, TICK_RATE } from '../src/shared/constants';
- import { Button } from '../src/shared/input';
- import type { FxEvent, SurpriseId } from '../src/shared/protocol';
- import { ASSETS, realMaterials } from './helpers';
- const materials = realMaterials();
- const PPM = PIXELS_PER_METER;
- function newRound(seed = 1, duration = 180): Game {
- const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
- return new Game({ map: definition, terrain, materials, seed, rules: { duration }, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
- }
- function run(game: Game, ticks: number): FxEvent[] {
- const fx: FxEvent[] = [];
- for (let i = 0; i < ticks; i++) fx.push(...game.step().fx);
- return fx;
- }
- const me = (game: Game, id: number) => game.snapshot().find((p) => p.id === id)!;
- const solid = (game: Game): number => game.terrain.data.reduce((n, m) => n + (m !== 0 ? 1 : 0), 0);
- interface CherryHandle {
- body: { setPosition(p: { x: number; y: number }): void; setLinearVelocity(v: { x: number; y: number }): void };
- }
- const cherryOf = (game: Game): CherryHandle => (game as unknown as { cherry: CherryHandle }).cherry;
- describe('surprises', () => {
- it('two of them happen during a round, at random times', () => {
- // A short round keeps the test fast: surprises come between 25 s and 20 s before the end.
- const game = newRound(3, 60);
- const fx = run(game, 60 * TICK_RATE);
- expect(fx.filter((e) => e.kind === 'surprise')).toHaveLength(2);
- });
- it('each one runs to its end without trouble, and the terrain stays consistent', () => {
- for (const id of Object.keys(SURPRISE_DURATIONS) as SurpriseId[]) {
- const game = newRound();
- game.addPlayer('Témoin', 'generic');
- run(game, 30);
- game.startSurprise(id);
- run(game, Math.ceil((SURPRISE_DURATIONS[id] + 1) * TICK_RATE));
- expect(game.roundOver, id).toBe(false);
- }
- });
- it('the cat paw scrapes the summit of the cake and sends whoever is up there flying', () => {
- const game = newRound();
- const { id } = game.addPlayer('Balayé', 'generic');
- game.teleport(id, 1024, 240); // top tier, the summit
- run(game, 60);
- const before = solid(game);
- game.startSurprise('cat-paw');
- let flung = false;
- for (let i = 0; i < SURPRISE_DURATIONS['cat-paw'] * TICK_RATE + 5; i++) {
- run(game, 1);
- if (Math.abs(me(game, id).vx) > 500) flung = true;
- }
- expect(flung).toBe(true);
- expect(solid(game)).toBeLessThan(before);
- });
- it('the giant hand grabs a fistful of cake and tosses whoever stands there', () => {
- const game = newRound(7);
- const { id } = game.addPlayer('Attrapé', 'generic');
- run(game, 10);
- const before = solid(game);
- const fx = run(game, 1).concat(((): FxEvent[] => {
- game.startSurprise('giant-hand');
- return run(game, 1);
- })());
- const hand = fx.find((e) => e.kind === 'surprise')!;
- expect(hand).toMatchObject({ surprise: 'giant-hand' });
- if (hand.kind !== 'surprise') return;
- // Stand right where the hand comes down.
- game.teleport(id, hand.x, hand.y - 20);
- let tossed = false;
- for (let i = 0; i < SURPRISE_DURATIONS['giant-hand'] * TICK_RATE; i++) {
- run(game, 1);
- if (me(game, id).vy < -600) tossed = true;
- }
- expect(tossed).toBe(true);
- expect(solid(game)).toBeLessThan(before - 5000);
- });
- it('sugar sprinkles rain down and nibble the cake', () => {
- const game = newRound();
- run(game, 10);
- const before = solid(game);
- game.startSurprise('sprinkles');
- const fx = run(game, (SURPRISE_DURATIONS.sprinkles + 2) * TICK_RATE);
- expect(fx.filter((e) => e.kind === 'explosion').length).toBeGreaterThan(20);
- expect(solid(game)).toBeLessThan(before);
- });
- });
- describe('the living cherry', () => {
- it('hops away from a player who comes close', () => {
- const game = newRound();
- const { id } = game.addPlayer('Chasseur', 'generic');
- run(game, 2 * TICK_RATE); // the cherry lands on the top tier
- const c = game.roundState().cherry!;
- game.teleport(id, c.x - 70, c.y - 10);
- const fx = run(game, 10);
- expect(fx.some((e) => e.kind === 'cherryHop')).toBe(true);
- });
- it('hot potato: held too long, it explodes in the holder’s hands', () => {
- const game = newRound();
- const { id } = game.addPlayer('Gourmand', 'generic');
- run(game, 2 * TICK_RATE);
- const p = me(game, id);
- cherryOf(game).body.setPosition({ x: p.x / PPM, y: p.y / PPM });
- run(game, 2);
- expect(game.roundState().cherry!.holder).toBe(id);
- const fx = run(game, Math.ceil(CHERRY_HOT_S * TICK_RATE));
- expect(fx).toContainEqual(expect.objectContaining({ kind: 'cherryBoom', id }));
- expect(game.roundState().cherry!.holder).not.toBe(id);
- });
- it('knocking it out of someone’s hands and grabbing it is a steal: bonus points', () => {
- const game = newRound();
- const holder = game.addPlayer('Volé', 'generic');
- const thief = game.addPlayer('Voleur', 'castor');
- game.teleport(holder.id, 600, 420);
- game.teleport(thief.id, 560, 420);
- run(game, 60);
- const h = me(game, holder.id);
- cherryOf(game).body.setPosition({ x: h.x / PPM, y: h.y / PPM });
- run(game, 2);
- expect(game.roundState().cherry!.holder).toBe(holder.id);
- // Bite: the cherry pops out; put it in the thief's mouth right away.
- const t = me(game, thief.id);
- game.setInput(thief.id, { buttons: Button.Attack, aimX: Math.round(h.x), aimY: Math.round(h.y) });
- run(game, 1);
- game.setInput(thief.id, { buttons: 0, aimX: 0, aimY: 0 });
- expect(game.roundState().cherry!.holder).toBe(0);
- cherryOf(game).body.setPosition({ x: t.x / PPM, y: t.y / PPM });
- cherryOf(game).body.setLinearVelocity({ x: 0, y: 0 });
- const fx = run(game, 2);
- expect(fx).toContainEqual(expect.objectContaining({ kind: 'stolen', id: thief.id, from: holder.id, bonus: CHERRY_STEAL_BONUS }));
- expect(me(game, thief.id).score).toBeGreaterThanOrEqual(CHERRY_STEAL_BONUS);
- });
- });
- describe('cartoon hits', () => {
- it('a small falling chunk flattens a player into a pancake instead of killing them', () => {
- const game = newRound();
- const { id } = game.addPlayer('Crêpe', 'generic');
- game.teleport(id, 400, 680); // bottom tier, open sky above
- run(game, 30);
- // A small block of biscuit (under CRUSH_KILL_PIXELS) dropped from high above.
- const { width, data } = game.terrain;
- const side = Math.floor(Math.sqrt(CRUSH_KILL_PIXELS / 2));
- // Built hanging in the air: it is not anchored, so the next detach search drops it.
- for (let y = 300; y < 300 + side; y++) for (let x = 400 - side / 2; x < 400 + side / 2; x++) data[y * width + Math.round(x)] = 5;
- game.terrain.markDirty({ x0: 300, y0: 280, x1: 500, y1: 300 + side + 10 });
- game.queueOp({ kind: 'carve', x: 400, y: 300 + side, r: 2, power: 99 }); // triggers the detach search there
- let crushedFlat = false;
- for (let i = 0; i < 2 * TICK_RATE && !crushedFlat; i++) {
- const res = game.step();
- if (res.crushed.some((c) => c.id === id && c.flat)) crushedFlat = true;
- }
- expect(crushedFlat).toBe(true);
- const p = me(game, id);
- expect(p.anim & Anim.Flat).toBeTruthy();
- expect(p.anim & Anim.Dead).toBe(0);
- });
- it('a player knocked into a wall goes SPLAF', () => {
- const game = newRound();
- const cat = game.addPlayer('Chat', 'chat');
- const victim = game.addPlayer('Victime', 'generic');
- game.teleport(cat.id, 420, 680); // bottom tier, middle tier's wall at x = 520
- game.teleport(victim.id, 470, 680);
- run(game, 30);
- const c = me(game, cat.id);
- game.setInput(cat.id, { buttons: Button.Special, aimX: 600, aimY: Math.round(c.y) });
- run(game, 1);
- game.setInput(cat.id, { buttons: 0, aimX: 600, aimY: Math.round(c.y) });
- const fx = run(game, 30);
- expect(fx).toContainEqual(expect.objectContaining({ kind: 'splat', id: victim.id }));
- });
- });
|