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 })); }); });