import { describe, expect, it } from 'vitest'; import { loadCharacterStats, loadMap } from '../src/server/assets'; import { Game } from '../src/server/game'; import { Anim, CHARACTER_IDS } from '../src/shared/characters'; import { MATERIAL_WOOL, 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(); /** `params` overrides character tuning values (by character id), e.g. to force a random outcome. */ function newGame(params: Record> = {}): Game { const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials); const loadCharacter = (id: string) => { const stats = loadCharacterStats(ASSETS, id); return { ...stats, params: { ...stats.params, ...params[id] } }; }; return new Game({ map: definition, terrain, materials, seed: 5, loadCharacter }); } 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 countWool = (game: Game): number => game.terrain.data.reduce((n, m) => n + (m === MATERIAL_WOOL ? 1 : 0), 0); /** Presses `buttons` aimed at (x, y) for one tick, then releases. */ function press(game: Game, id: number, buttons: number, x: number, y: number): FxEvent[] { game.setInput(id, { buttons, aimX: Math.round(x), aimY: Math.round(y) }); const fx = run(game, 1); game.setInput(id, { buttons: 0, aimX: Math.round(x), aimY: Math.round(y) }); return fx; } describe('roster', () => { it('every character has data that loads', () => { expect(CHARACTER_IDS).toEqual(['castor', 'mamie', 'pyromanchot', 'gege', 'pompier', 'prout', 'chat', 'magicien', 'generic']); for (const id of CHARACTER_IDS) expect(loadCharacterStats(ASSETS, id).id).toBe(id); }); }); describe('Mamie Tricot', () => { it('her yarn ball lays a taut wool bridge that holds: nothing falls, nobody is hurt', () => { const game = newGame(); const { id } = game.addPlayer('Mamie', 'mamie'); game.teleport(id, 640, 440); // middle tier, left of the top tier wall (x ≈ 780) run(game, 60); const m = me(game, id); let detached = 0; let crushed = 0; game.setInput(id, { buttons: Button.Special, aimX: 800, aimY: Math.round(m.y - 40) }); for (let i = 0; i < 4 * TICK_RATE; i++) { const r = game.step(); if (i === 0) game.setInput(id, { buttons: 0, aimX: 800, aimY: Math.round(m.y - 40) }); detached += r.terrain.filter((e) => e.ev.kind === 'detach').length; crushed += r.crushed.length; } expect(countWool(game)).toBeGreaterThan(300); expect(detached).toBe(0); expect(crushed).toBe(0); expect(me(game, id).anim & Anim.Dead).toBe(0); }); it('the thread follows the ball in flight, tied under her feet', () => { const game = newGame(); const { id } = game.addPlayer('Mamie', 'mamie'); game.teleport(id, 640, 440); run(game, 60); const m = me(game, id); press(game, id, Button.Special, 800, m.y - 40); const ball = game.itemStates().find((it) => it.kind === 'yarn')!; expect(ball.thread).toBeDefined(); const [ax, ay] = ball.thread!; expect(Math.abs(ax - m.x)).toBeLessThan(20); expect(ay).toBeGreaterThan(m.y); // on the ground under her expect(game.terrain.isSolid(Math.round(ax), Math.round(ay))).toBe(true); }); it('a yarn ball thrown at her own feet never buries or kills her', () => { const game = newGame(); const { id } = game.addPlayer('Mamie', 'mamie'); game.teleport(id, 640, 440); run(game, 60); for (const [ax, ay] of [[0, 60], [0, -200], [40, 20], [-40, 20]]) { const m = me(game, id); press(game, id, Button.Special, m.x + ax, m.y + ay); run(game, 3 * TICK_RATE); const after = me(game, id); expect(after.anim & Anim.Dead).toBe(0); // Not stuck inside wool: no solid pixel at her centre. expect(game.terrain.isSolid(Math.round(after.x), Math.round(after.y))).toBe(false); } }); it('her needles barely push but stun for a long time', () => { const game = newGame(); const mamie = game.addPlayer('Mamie', 'mamie'); const victim = game.addPlayer('Victime', 'generic'); run(game, 90); const m = me(game, mamie.id); game.teleport(victim.id, m.x + 40, m.y); press(game, mamie.id, Button.Attack, m.x + 40, m.y); const v = me(game, victim.id); expect(v.anim & Anim.Stunned).toBeTruthy(); expect(Math.hypot(v.vx, v.vy)).toBeLessThan(400); run(game, Math.round(0.4 * TICK_RATE)); expect(me(game, victim.id).anim & Anim.Stunned).toBeTruthy(); }); it('her wool burns: an explosion nearby consumes it', () => { const game = newGame(); const mamie = game.addPlayer('Mamie', 'mamie'); const pyro = game.addPlayer('Pyro', 'pyromanchot'); game.teleport(mamie.id, 640, 440); run(game, 60); const m = me(game, mamie.id); press(game, mamie.id, Button.Special, 800, m.y - 40); run(game, 2 * TICK_RATE); const before = countWool(game); expect(before).toBeGreaterThan(0); // A firecracker thrown into the wool. game.teleport(pyro.id, 700, 300); run(game, 2); const fx = press(game, pyro.id, Button.Attack, 760, m.y - 30).concat(run(game, TICK_RATE)); expect(fx.some((e) => e.kind === 'explosion')).toBe(true); run(game, 2); expect(countWool(game)).toBeLessThan(before); }); }); describe('Pyromanchot', () => { it('throws firecrackers far away from him', () => { const game = newGame({ pyromanchot: { backfireChance: 0 } }); const { id } = game.addPlayer('Pyro', 'pyromanchot'); run(game, 90); const explosions: { x: number; y: number }[] = []; for (let shot = 0; shot < 3; shot++) { const p = me(game, id); // Aimed up and across: it flies until it lands somewhere on the cake. const fx = press(game, id, Button.Attack, p.x + 300, p.y - 300).concat(run(game, 2 * TICK_RATE)); for (const e of fx) if (e.kind === 'explosion') explosions.push({ x: e.x - p.x, y: e.y - p.y }); } expect(explosions).toHaveLength(3); for (const e of explosions) expect(Math.hypot(e.x, e.y)).toBeGreaterThan(200); }); it('only some firecrackers backfire', () => { const game = newGame(); const { id } = game.addPlayer('Pyro', 'pyromanchot'); run(game, 90); let backfires = 0; const shots = 40; for (let shot = 0; shot < shots; shot++) { const p = me(game, id); const fx = press(game, id, Button.Attack, p.x + 300, p.y - 300); if (fx.some((e) => e.kind === 'cue' && e.cue === 'backfire')) backfires++; run(game, Math.round(0.5 * TICK_RATE)); } expect(backfires).toBeGreaterThan(0); expect(backfires).toBeLessThan(shots / 4); }); it('a backfire stuns him', () => { const game = newGame({ pyromanchot: { backfireChance: 1 } }); const { id } = game.addPlayer('Pyro', 'pyromanchot'); run(game, 90); const p = me(game, id); press(game, id, Button.Attack, p.x + 300, p.y - 100); expect(me(game, id).anim & Anim.Stunned).toBeTruthy(); }); it('belly slide: fast, then an explosion at the end that spares him', () => { const game = newGame(); const pyro = game.addPlayer('Pyro', 'pyromanchot'); const victim = game.addPlayer('Victime', 'generic'); game.teleport(pyro.id, 900, 680); // bottom tier, lots of room to the right game.teleport(victim.id, 1500, 680); run(game, 60); const start = me(game, pyro.id); press(game, pyro.id, Button.Special, start.x + 500, start.y); expect(me(game, pyro.id).anim & Anim.Special).toBeTruthy(); const fx = run(game, TICK_RATE); const end = me(game, pyro.id); expect(end.x - start.x).toBeGreaterThan(200); expect(fx.some((e) => e.kind === 'explosion')).toBe(true); expect(end.anim & Anim.Stunned).toBe(0); }); }); describe('Gégé le Ver', () => { it('swims down into the cake, then bursts out under a player and sends them flying', () => { const game = newGame(); const gege = game.addPlayer('Gégé', 'gege'); const victim = game.addPlayer('Victime', 'generic'); game.teleport(gege.id, 600, 420); // middle tier top game.teleport(victim.id, 730, 420); run(game, 60); // Dive. for (let i = 0; i < 40; i++) { const g = me(game, gege.id); game.setInput(gege.id, { buttons: Button.Special, aimX: Math.round(g.x), aimY: Math.round(g.y + 200) }); game.step(); } expect(me(game, gege.id).anim & Anim.Underground).toBeTruthy(); // Tunnel toward the victim, under them, then let go: he bursts out. for (let i = 0; i < 200; i++) { const g = me(game, gege.id); const v = me(game, victim.id); const under = Math.abs(g.x - v.x) < 20; game.setInput(gege.id, { buttons: Button.Special, aimX: Math.round(v.x), aimY: Math.round(under ? v.y : 505) }); game.step(); if (under && g.y - v.y < 70) break; } game.setInput(gege.id, { buttons: 0, aimX: 0, aimY: 0 }); const fx = run(game, 2); expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', id: gege.id, cue: 'emerge' })); const v = me(game, victim.id); expect(v.anim & Anim.Stunned).toBeTruthy(); expect(v.vy).toBeLessThan(-300); }); }); describe('Bébé Pompier', () => { it('the water jet pushes a player away and the recoil sends him backwards', () => { const game = newGame(); const baby = game.addPlayer('Bébé', 'pompier'); const victim = game.addPlayer('Victime', 'generic'); game.teleport(baby.id, 600, 420); run(game, 60); const b = me(game, baby.id); game.teleport(victim.id, b.x + 80, b.y); run(game, 1); const fx = press(game, baby.id, Button.Attack, b.x + 200, b.y); expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', cue: 'water' })); expect(me(game, victim.id).vx).toBeGreaterThan(200); expect(me(game, baby.id).vx).toBeLessThan(-200); }); it('soaking turns sponge into soaked sponge, which then sags on its own', () => { const game = newGame(); const baby = game.addPlayer('Bébé', 'pompier'); game.teleport(baby.id, 600, 420); run(game, 60); const b = me(game, baby.id); press(game, baby.id, Button.Special, b.x + 60, b.y + 120); const soaked = (): number => game.terrain.data.reduce((n, m) => n + (m === 8 ? 1 : 0), 0); const after = soaked(); expect(after).toBeGreaterThan(1000); run(game, 4 * TICK_RATE); expect(soaked()).toBeLessThan(after); // crumbling }); it('the spray drenches players caught in it: slower for a while', () => { const game = newGame(); const baby = game.addPlayer('Bébé', 'pompier'); const victim = game.addPlayer('Victime', 'generic'); game.teleport(baby.id, 600, 420); run(game, 60); const b = me(game, baby.id); game.teleport(victim.id, b.x + 100, b.y); run(game, 1); press(game, baby.id, Button.Special, b.x + 100, b.y); expect(me(game, victim.id).anim & Anim.Soaked).toBeTruthy(); expect(me(game, baby.id).anim & Anim.Soaked).toBeFalsy(); }); }); describe('Captain Prout', () => { it('each thrust lifts him, is heard by all, and leaves a cloud that poisons players inside', () => { const game = newGame(); const prout = game.addPlayer('Prout', 'prout'); const victim = game.addPlayer('Victime', 'generic'); game.teleport(prout.id, 600, 420); run(game, 60); const p0 = me(game, prout.id); const fx = press(game, prout.id, Button.Special, p0.x, p0.y - 100); expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', id: prout.id, cue: 'prout' })); expect(me(game, prout.id).vy).toBeLessThan(-300); expect(game.roundState().clouds.length).toBe(1); const [cx, cy] = game.roundState().clouds[0]; game.teleport(victim.id, cx, cy); run(game, 2); expect(me(game, victim.id).anim & Anim.Poisoned).toBeTruthy(); run(game, 1); game.teleport(prout.id, cx, cy); run(game, 2); expect(me(game, prout.id).anim & Anim.Poisoned).toBeFalsy(); // immune to his own clouds }); it('only a few thrusts before touching the ground again: he hops, he does not fly', () => { const game = newGame(); const prout = game.addPlayer('Prout', 'prout'); game.teleport(prout.id, 600, 420); run(game, 60); let puffs = 0; // One try per cooldown: he does not land before the fourth one. for (let i = 0; i < 4; i++) { const p = me(game, prout.id); const fx = press(game, prout.id, Button.Special, p.x, p.y - 100); if (fx.some((e) => e.kind === 'cue' && e.cue === 'prout')) puffs++; if (i < 3) { run(game, 24); expect(me(game, prout.id).grounded).toBe(false); } } expect(puffs).toBe(loadCharacterStats(ASSETS, 'prout').params.airPuffs); }); }); describe('Le Chat', () => { it('pounces claws first: digs scratches and sends the player he lands on flying', () => { const game = newGame(); const cat = game.addPlayer('Chat', 'chat'); const victim = game.addPlayer('Victime', 'generic'); game.teleport(cat.id, 600, 420); run(game, 60); const c = me(game, cat.id); game.teleport(victim.id, c.x + 90, c.y); run(game, 1); const before = game.terrain.data.reduce((n, m) => n + (m !== 0 ? 1 : 0), 0); const fx = press(game, cat.id, Button.Special, c.x + 200, c.y + 30); expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', cue: 'pounce' })); let fastest = 0; for (let i = 0; i < 20; i++) { run(game, 1); const v = me(game, victim.id); fastest = Math.max(fastest, Math.hypot(v.vx, v.vy)); } expect(fastest).toBeGreaterThan(300); expect(game.terrain.data.reduce((n, m) => n + (m !== 0 ? 1 : 0), 0)).toBeLessThan(before); }); it('shoves a stunned player hard', () => { const game = newGame(); const cat = game.addPlayer('Chat', 'chat'); const a = game.addPlayer('Assommé', 'generic'); game.teleport(cat.id, 600, 420); run(game, 60); const c = me(game, cat.id); game.teleport(a.id, c.x + 50, c.y); run(game, 1); press(game, cat.id, Button.Attack, c.x + 50, c.y); // paw: stuns expect(me(game, a.id).anim & Anim.Stunned).toBeTruthy(); const now = me(game, cat.id); game.teleport(a.id, now.x + 55 * now.facing, now.y); press(game, cat.id, Button.Special, now.x + 200 * now.facing, now.y); expect(Math.abs(me(game, a.id).vx)).toBeGreaterThan(400); }); it('stops to lick his paw now and then, ignoring input', () => { const game = newGame(); const cat = game.addPlayer('Chat', 'chat'); run(game, 60); let licked = false; for (let i = 0; i < 26 * TICK_RATE && !licked; i++) licked = game.step().fx.some((e) => e.kind === 'cue' && e.cue === 'lick'); expect(licked).toBe(true); expect(me(game, cat.id).anim & Anim.Busy).toBeTruthy(); const x0 = me(game, cat.id).x; game.setInput(cat.id, { buttons: Button.Right, aimX: 0, aimY: 0 }); run(game, 20); expect(Math.abs(me(game, cat.id).x - x0)).toBeLessThan(5); }); }); describe('Magicien Raté', () => { function castMany(leading: boolean, casts: number): Record { const game = newGame(); const mage = game.addPlayer('Mage', 'magicien'); const other = game.addPlayer('Autre', 'generic'); game.teleport(mage.id, 600, 420); game.teleport(other.id, 1400, 420); run(game, 60); // Make him the leader (or last) by giving round points. const players = (game as unknown as { players: Map }).players; players.get(mage.id)!.score = leading ? 50 : 0; players.get(other.id)!.score = leading ? 0 : 50; const counts: Record = {}; for (let i = 0; i < casts; i++) { const m = me(game, mage.id); const fx = press(game, mage.id, Button.Special, m.x + 100, m.y - 50); for (const e of fx) if (e.kind === 'cue' && e.cue.startsWith('spell-')) counts[e.cue] = (counts[e.cue] ?? 0) + 1; run(game, Math.ceil(2.3 * TICK_RATE)); } return counts; } it('casts every kind of spell, and fizzles far more often when leading', () => { const last = castMany(false, 40); expect(Object.keys(last).sort()).toEqual(['spell-jelly', 'spell-pfft', 'spell-rabbit', 'spell-swap']); const lead = castMany(true, 40); expect(lead['spell-pfft'] ?? 0).toBeGreaterThan((last['spell-pfft'] ?? 0) * 2); }); it('the giant rabbit appears in the air and falls (an unanchored sponge chunk)', () => { const game = newGame(); const mage = game.addPlayer('Mage', 'magicien'); game.teleport(mage.id, 600, 420); run(game, 60); for (let i = 0; i < 60; i++) { const m = me(game, mage.id); game.setInput(mage.id, { buttons: Button.Special, aimX: Math.round(m.x + 100), aimY: Math.round(m.y - 50) }); const r = game.step(); game.setInput(mage.id, { buttons: 0, aimX: 0, aimY: 0 }); if (!r.fx.some((e) => e.kind === 'cue' && e.cue === 'spell-rabbit')) { run(game, Math.ceil(2.3 * TICK_RATE)); continue; } // The rabbit is filled in this very tick, and comes loose in the same detach pass. expect(r.terrain.some((e) => e.ev.kind === 'detach' && !e.ev.debris)).toBe(true); return; } throw new Error('no rabbit in 60 casts'); }); });