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, PLAYABLE_CHARACTER_IDS } from '../src/shared/characters'; import { CHERRY_REGRAB_DELAY, FINAL_RUSH_S, MIN_CAKE_PIXELS, RESPAWN_DELAY_S, SMARTIES_DURATION_S, STUN_GUARD_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 spoon finds the last little island of cake', () => { const game = newRound(); // Wipe the cake, plate excluded, except for a 120 px block on the far right. const { width, height, data } = game.terrain; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = y * width + x; if (data[i] !== 255 && !(x >= 1600 && x < 1720 && y >= 820)) data[i] = 0; } } game.terrain.markDirty({ x0: 0, y0: 0, x1: width, y1: height }); const { id } = game.addPlayer('Plouf', 'generic'); game.teleport(id, 60, 900); // left of the plate: nothing below but custard 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)).toEqual([]); const p = me(game, id); expect(p.grounded).toBe(true); expect(p.x).toBeGreaterThan(1590); expect(p.x).toBeLessThan(1730); }); 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); expect(game.endReason).toBe('time'); }); it('the round ends early when almost no cake is left above the custard', () => { const game = newRound(); run(game, 2 * TICK_RATE); expect(game.roundOver).toBe(false); const { data } = game.terrain; let kept = 0; for (let i = 0; i < data.length; i++) { if (data[i] === 0 || data[i] === 255) continue; if (kept < MIN_CAKE_PIXELS / 2) kept++; else data[i] = 0; } run(game, 2 * TICK_RATE); expect(game.roundOver).toBe(true); expect(game.endReason).toBe('noCake'); }); }); describe('fair fights', () => { it('a stun cannot be chained: after one, the player is immune for a moment', () => { const game = newRound(); const mamie = game.addPlayer('Mamie', 'mamie'); const victim = game.addPlayer('Victime', 'generic'); run(game, 90); let stunnedTicks = 0; let longest = 0; let streak = 0; // Mamie jabs as fast as she can for 4 s, the victim held right in front of her. for (let i = 0; i < 4 * TICK_RATE; i++) { const m = me(game, mamie.id); game.teleport(victim.id, m.x + 36, m.y); game.setInput(mamie.id, { buttons: Button.Attack, aimX: Math.round(m.x + 36), aimY: Math.round(m.y) }); game.step(); const stunned = (me(game, victim.id).anim & Anim.Stunned) !== 0; if (stunned) stunnedTicks++; streak = stunned ? streak + 1 : 0; longest = Math.max(longest, streak); } const stun = loadCharacterStats(ASSETS, 'mamie').params.needleStun; expect(longest).toBeLessThanOrEqual(Math.ceil(stun * TICK_RATE) + 1); // Free for at least the guard time after each stun. expect(stunnedTicks / (4 * TICK_RATE)).toBeLessThan(stun / (stun + STUN_GUARD_S) + 0.1); }); it('a stuck player can give up: into the custard, back with the spoon', () => { const game = newRound(); const { id } = game.addPlayer('Coincé', 'generic'); run(game, 60); game.setInput(id, { buttons: Button.Suicide, aimX: 0, aimY: 0 }); const fx = run(game, 1); game.setInput(id, { buttons: 0, aimX: 0, aimY: 0 }); expect(fx).toContainEqual(expect.objectContaining({ kind: 'splash', id })); expect(me(game, id).anim & Anim.Dead).toBeTruthy(); run(game, (RESPAWN_DELAY_S + 0.5) * TICK_RATE); expect(me(game, id).anim & Anim.Dead).toBe(0); }); }); 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(); }); }); describe('getting up', () => { /** Holds toward the wall and taps jump, like a player climbing; returns the highest y reached. */ function climb(game: Game, id: number, ticks: number): number { let best = Infinity; for (let i = 0; i < ticks; i++) { const jump = i % 12 < 6 ? Button.Jump : 0; game.setInput(id, { buttons: Button.Right | jump, aimX: 2000, aimY: 0 }); game.step(); best = Math.min(best, me(game, id).y); } return best; } it('every character can climb a tier wall by wall-jumping', () => { for (const character of PLAYABLE_CHARACTER_IDS) { const game = newRound(); const { id } = game.addPlayer('Grimpeur', character); game.teleport(id, 470, 670); // bottom tier, against the middle tier's wall (x = 520, 230 px high) run(game, 30); expect(climb(game, id, 6 * TICK_RATE), character).toBeLessThan(470); } }); it('the cherry is heavy: its holder jumps lower', () => { const jumpHeight = (holding: boolean): number => { const game = newRound(); const { id } = game.addPlayer('Sauteur', 'generic'); game.teleport(id, 400, 680); run(game, 30); if (holding) { const cherry = (game as unknown as { cherry: { body: { setPosition(p: { x: number; y: number }): void } } }).cherry; const p = me(game, id); cherry.body.setPosition({ x: p.x / 32, y: p.y / 32 }); run(game, 2); expect(game.roundState().cherry!.holder).toBe(id); } const y0 = me(game, id).y; game.setInput(id, { buttons: Button.Jump, aimX: 0, aimY: 0 }); let top = y0; for (let i = 0; i < TICK_RATE; i++) { game.step(); top = Math.min(top, me(game, id).y); } return y0 - top; }; expect(jumpHeight(true)).toBeLessThan(jumpHeight(false) * 0.75); }); it('a cherry that falls in the custard comes back on the cake, not always at the top', () => { const tops = new Set(); for (const seed of [1, 2, 3, 4]) { const game = newRound(180, seed); const cherry = (game as unknown as { cherry: { body: { setPosition(p: { x: number; y: number }): void } } }).cherry; cherry.body.setPosition({ x: 100 / 32, y: 1010 / 32 }); run(game, 2); const c = game.roundState().cherry!; expect(c.y).toBeLessThan(game.custardLevel); run(game, TICK_RATE); // it lands on something expect(game.roundState().cherry!.y).toBeLessThan(game.custardLevel - 10); tops.add(Math.round(c.y / 100)); } expect(tops.size).toBeGreaterThan(1); }); });