| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- import { describe, expect, it } from 'vitest';
- import { loadCharacterStats, loadMap } from '../src/server/assets';
- import { Game } from '../src/server/game';
- import { MatchStats } from '../src/server/stats';
- import { Anim } from '../src/shared/characters';
- import { CHERRY_REGRAB_DELAY, FINAL_RUSH_S, RESPAWN_DELAY_S, SMARTIES_DURATION_S, TICK_RATE } from '../src/shared/constants';
- import { Button } from '../src/shared/input';
- import type { FxEvent } from '../src/shared/protocol';
- import { ASSETS, realMaterials } from './helpers';
- const materials = realMaterials();
- function newRound(duration = 180, seed = 1): 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)!;
- describe('custard and the spoon', () => {
- it('rises during a round, faster in the final rush, and stays put during the warm-up', () => {
- const round = newRound(40);
- const start = round.custardLevel;
- run(round, 5 * TICK_RATE);
- const slow = start - round.custardLevel;
- expect(slow).toBeCloseTo(round.map.custard.speed * 5, 0);
- run(round, (40 - FINAL_RUSH_S) * TICK_RATE); // into the final rush
- const before = round.custardLevel;
- run(round, 5 * TICK_RATE);
- expect(before - round.custardLevel).toBeCloseTo(round.map.custard.finalRushSpeed * 5, 0);
- expect(round.roundState().finalRush).toBe(true);
- const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
- const warmup = new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
- run(warmup, 5 * TICK_RATE);
- expect(warmup.custardLevel).toBe(definition.custard.startY);
- expect(warmup.roundState()).toMatchObject({ timeLeft: null, cherry: null });
- });
- it('a player falling in is out for 5 s, then the spoon launches them back from open air', () => {
- const game = newRound();
- const { id } = game.addPlayer('Plouf', 'generic');
- game.teleport(id, 100, 900); // left of the cake: nothing below but custard
- const fx = run(game, TICK_RATE);
- expect(fx).toContainEqual(expect.objectContaining({ kind: 'splash', id }));
- expect(me(game, id).anim & Anim.Dead).toBeTruthy();
- expect(game.stats.get(id, 'custard')).toBe(1);
- const later = run(game, RESPAWN_DELAY_S * TICK_RATE);
- const spoon = later.find((e) => e.kind === 'spoon');
- expect(spoon).toBeDefined();
- const p = me(game, id);
- expect(p.anim & Anim.Dead).toBe(0);
- expect(p.y).toBeLessThan(game.custardLevel);
- // ...and lands on the cake instead of falling straight back in.
- const flight = run(game, 3 * TICK_RATE);
- expect(flight.filter((e) => e.kind === 'splash' && e.id === id)).toEqual([]);
- expect(me(game, id).grounded).toBe(true);
- });
- it('the spoon lands players back on the cake whatever spawn it rolls (several seeds)', () => {
- for (const seed of [1, 2, 3, 4, 5, 6]) {
- const game = newRound(180, seed);
- const { id } = game.addPlayer('Plouf', 'generic');
- game.teleport(id, 100, 900);
- run(game, (RESPAWN_DELAY_S + 0.5) * TICK_RATE);
- const fx = run(game, 3 * TICK_RATE);
- expect(fx.filter((e) => e.kind === 'splash' && e.id === id), `seed ${seed}`).toEqual([]);
- }
- });
- it('the round ends when the timer runs out', () => {
- const game = newRound(2);
- run(game, 2 * TICK_RATE - 1);
- expect(game.roundOver).toBe(false);
- run(game, 2);
- expect(game.roundOver).toBe(true);
- });
- });
- describe('the cherry', () => {
- it('is picked up by touching it and scores 1 point per second', () => {
- const game = newRound();
- const { id } = game.addPlayer('Gourmand', 'generic');
- const [cx, cy] = game.map.cherrySpawn;
- run(game, 30); // let the cherry settle on the top tier
- const cherry = game.roundState().cherry!;
- game.teleport(id, cherry.x, cherry.y - 10);
- const fx = run(game, 10);
- expect(fx).toContainEqual(expect.objectContaining({ kind: 'pickup', id, item: 'cherry' }));
- expect(game.roundState().cherry!.holder).toBe(id);
- run(game, 3 * TICK_RATE);
- expect(me(game, id).score).toBeGreaterThanOrEqual(3);
- expect([cx, cy]).toEqual(game.map.cherrySpawn);
- });
- it('a hit holder drops it, and cannot grab it back right away', () => {
- const game = newRound();
- const holder = game.addPlayer('Porteur', 'generic');
- const biter = game.addPlayer('Castor', 'castor');
- run(game, 30);
- const cherry = game.roundState().cherry!;
- game.teleport(holder.id, cherry.x, cherry.y - 10);
- run(game, 10);
- expect(game.roundState().cherry!.holder).toBe(holder.id);
- const h = me(game, holder.id);
- game.teleport(biter.id, h.x - 45, h.y);
- game.setInput(biter.id, { buttons: Button.Attack, aimX: Math.round(h.x), aimY: Math.round(h.y) });
- const fx = run(game, 2);
- expect(fx).toContainEqual(expect.objectContaining({ kind: 'cherryDrop', id: holder.id }));
- expect(game.roundState().cherry!.holder).not.toBe(holder.id);
- expect(game.stats.get(biter.id, 'hits')).toBeGreaterThanOrEqual(1);
- // Still blocked for the holder for a moment.
- game.setInput(biter.id, { buttons: 0, aimX: 0, aimY: 0 });
- run(game, Math.floor(CHERRY_REGRAB_DELAY * TICK_RATE * 0.5));
- expect(game.roundState().cherry!.holder).not.toBe(holder.id);
- });
- });
- describe('items', () => {
- it('a candle is picked up, thrown with Use, and explodes: carves the cake and blows players away', () => {
- const game = newRound();
- const { id } = game.addPlayer('Artificier', 'generic');
- run(game, 90);
- const p = me(game, id);
- game.spawnItem('candle', p.x, p.y - 30);
- const fx = run(game, 20);
- expect(fx).toContainEqual(expect.objectContaining({ kind: 'pickup', id, item: 'candle' }));
- expect(me(game, id).item).toBe('candle');
- const eventsBefore = game.events.length;
- game.setInput(id, { buttons: Button.Use, aimX: Math.round(p.x + 60), aimY: Math.round(p.y + 80) });
- const after = run(game, 2 * TICK_RATE);
- expect(me(game, id).item).toBeNull();
- expect(after).toContainEqual(expect.objectContaining({ kind: 'explosion' }));
- expect(game.events.length).toBeGreaterThan(eventsBefore);
- expect(game.stats.get(id, 'candles')).toBe(1);
- });
- it('smarties boost the speed for a while', () => {
- const game = newRound();
- const { id } = game.addPlayer('Speedy', 'generic');
- run(game, 90);
- const p = me(game, id);
- game.spawnItem('smarties', p.x, p.y - 30);
- run(game, 20);
- expect(me(game, id).anim & Anim.Boosted).toBeTruthy();
- run(game, SMARTIES_DURATION_S * TICK_RATE);
- expect(me(game, id).anim & Anim.Boosted).toBe(0);
- });
- it('the electric whisk jolts the others and shakes the jelly', () => {
- const game = newRound();
- const a = game.addPlayer('Fouet', 'generic');
- const b = game.addPlayer('Victime', 'generic');
- run(game, 90);
- const p = me(game, a.id);
- const eventsBefore = game.events.length;
- game.spawnItem('whisk', p.x, p.y - 30);
- let whisked = false;
- for (let i = 0; i < 30 && !whisked; i++) whisked = game.step().fx.some((e) => e.kind === 'whisk' && e.id === a.id);
- expect(whisked).toBe(true);
- // Same tick: the other player is jolted and stunned.
- expect(me(game, b.id).anim & Anim.Stunned).toBeTruthy();
- run(game, 2);
- expect(game.events.length).toBeGreaterThan(eventsBefore + 5);
- expect(game.stats.get(a.id, 'eaten')).toBeGreaterThan(0);
- });
- it('items fall from the sky on their own during a round (seeded)', () => {
- const a = newRound(180, 7);
- const b = newRound(180, 7);
- // The first item drops within map.items.interval[1] seconds; both games drop the same ones.
- let seen = 0;
- for (let i = 0; i < a.map.items.interval[1] * TICK_RATE + 2; i++) {
- a.step();
- b.step();
- seen = Math.max(seen, a.itemStates().length);
- }
- expect(seen).toBeGreaterThan(0);
- expect(a.itemStates()).toEqual(b.itemStates());
- });
- });
- describe('trophies', () => {
- it('go to the best player of each stat, only if they did it', () => {
- const stats = new MatchStats();
- stats.add(1, 'eaten', 5000);
- stats.add(2, 'eaten', 300);
- stats.add(2, 'custard', 3);
- stats.add(3, 'crushed', 1);
- stats.add(3, 'hitTaken', 4);
- const t = stats.trophies([1, 2, 3]);
- expect(t).toContainEqual({ id: 'glutton', playerId: 1, value: 5000 });
- expect(t).toContainEqual({ id: 'kamikaze', playerId: 2, value: 3 });
- expect(t).toContainEqual({ id: 'notMyFault', playerId: 3, value: 5 });
- expect(t.find((x) => x.id === 'pyromaniac')).toBeUndefined();
- });
- });
|