fun.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import { describe, expect, it } from 'vitest';
  2. import { loadCharacterStats, loadMap } from '../src/server/assets';
  3. import { Game } from '../src/server/game';
  4. import { Anim } from '../src/shared/characters';
  5. import { CHERRY_HOT_S, CHERRY_STEAL_BONUS, CRUSH_KILL_PIXELS, PIXELS_PER_METER, SURPRISE_DURATIONS, TICK_RATE } from '../src/shared/constants';
  6. import { Button } from '../src/shared/input';
  7. import type { FxEvent, SurpriseId } from '../src/shared/protocol';
  8. import { ASSETS, realMaterials } from './helpers';
  9. const materials = realMaterials();
  10. const PPM = PIXELS_PER_METER;
  11. function newRound(seed = 1, duration = 180): Game {
  12. const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
  13. return new Game({ map: definition, terrain, materials, seed, rules: { duration }, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
  14. }
  15. function run(game: Game, ticks: number): FxEvent[] {
  16. const fx: FxEvent[] = [];
  17. for (let i = 0; i < ticks; i++) fx.push(...game.step().fx);
  18. return fx;
  19. }
  20. const me = (game: Game, id: number) => game.snapshot().find((p) => p.id === id)!;
  21. const solid = (game: Game): number => game.terrain.data.reduce((n, m) => n + (m !== 0 ? 1 : 0), 0);
  22. interface CherryHandle {
  23. body: { setPosition(p: { x: number; y: number }): void; setLinearVelocity(v: { x: number; y: number }): void };
  24. }
  25. const cherryOf = (game: Game): CherryHandle => (game as unknown as { cherry: CherryHandle }).cherry;
  26. describe('surprises', () => {
  27. it('two of them happen during a round, at random times', () => {
  28. // A short round keeps the test fast: surprises come between 25 s and 20 s before the end.
  29. const game = newRound(3, 60);
  30. const fx = run(game, 60 * TICK_RATE);
  31. expect(fx.filter((e) => e.kind === 'surprise')).toHaveLength(2);
  32. });
  33. it('each one runs to its end without trouble, and the terrain stays consistent', () => {
  34. for (const id of Object.keys(SURPRISE_DURATIONS) as SurpriseId[]) {
  35. const game = newRound();
  36. game.addPlayer('Témoin', 'generic');
  37. run(game, 30);
  38. game.startSurprise(id);
  39. run(game, Math.ceil((SURPRISE_DURATIONS[id] + 1) * TICK_RATE));
  40. expect(game.roundOver, id).toBe(false);
  41. }
  42. });
  43. it('the cat paw scrapes the summit of the cake and sends whoever is up there flying', () => {
  44. const game = newRound();
  45. const { id } = game.addPlayer('Balayé', 'generic');
  46. game.teleport(id, 1024, 240); // top tier, the summit
  47. run(game, 60);
  48. const before = solid(game);
  49. game.startSurprise('cat-paw');
  50. let flung = false;
  51. for (let i = 0; i < SURPRISE_DURATIONS['cat-paw'] * TICK_RATE + 5; i++) {
  52. run(game, 1);
  53. if (Math.abs(me(game, id).vx) > 500) flung = true;
  54. }
  55. expect(flung).toBe(true);
  56. expect(solid(game)).toBeLessThan(before);
  57. });
  58. it('the giant hand grabs a fistful of cake and tosses whoever stands there', () => {
  59. const game = newRound(7);
  60. const { id } = game.addPlayer('Attrapé', 'generic');
  61. run(game, 10);
  62. const before = solid(game);
  63. const fx = run(game, 1).concat(((): FxEvent[] => {
  64. game.startSurprise('giant-hand');
  65. return run(game, 1);
  66. })());
  67. const hand = fx.find((e) => e.kind === 'surprise')!;
  68. expect(hand).toMatchObject({ surprise: 'giant-hand' });
  69. if (hand.kind !== 'surprise') return;
  70. // Stand right where the hand comes down.
  71. game.teleport(id, hand.x, hand.y - 20);
  72. let tossed = false;
  73. for (let i = 0; i < SURPRISE_DURATIONS['giant-hand'] * TICK_RATE; i++) {
  74. run(game, 1);
  75. if (me(game, id).vy < -600) tossed = true;
  76. }
  77. expect(tossed).toBe(true);
  78. expect(solid(game)).toBeLessThan(before - 5000);
  79. });
  80. it('sugar sprinkles rain down and nibble the cake', () => {
  81. const game = newRound();
  82. run(game, 10);
  83. const before = solid(game);
  84. game.startSurprise('sprinkles');
  85. const fx = run(game, (SURPRISE_DURATIONS.sprinkles + 2) * TICK_RATE);
  86. expect(fx.filter((e) => e.kind === 'explosion').length).toBeGreaterThan(20);
  87. expect(solid(game)).toBeLessThan(before);
  88. });
  89. });
  90. describe('the living cherry', () => {
  91. it('hops away from a player who comes close', () => {
  92. const game = newRound();
  93. const { id } = game.addPlayer('Chasseur', 'generic');
  94. run(game, 2 * TICK_RATE); // the cherry lands on the top tier
  95. const c = game.roundState().cherry!;
  96. game.teleport(id, c.x - 70, c.y - 10);
  97. const fx = run(game, 10);
  98. expect(fx.some((e) => e.kind === 'cherryHop')).toBe(true);
  99. });
  100. it('hot potato: held too long, it explodes in the holder’s hands', () => {
  101. const game = newRound();
  102. const { id } = game.addPlayer('Gourmand', 'generic');
  103. run(game, 2 * TICK_RATE);
  104. const p = me(game, id);
  105. cherryOf(game).body.setPosition({ x: p.x / PPM, y: p.y / PPM });
  106. run(game, 2);
  107. expect(game.roundState().cherry!.holder).toBe(id);
  108. const fx = run(game, Math.ceil(CHERRY_HOT_S * TICK_RATE));
  109. expect(fx).toContainEqual(expect.objectContaining({ kind: 'cherryBoom', id }));
  110. expect(game.roundState().cherry!.holder).not.toBe(id);
  111. });
  112. it('knocking it out of someone’s hands and grabbing it is a steal: bonus points', () => {
  113. const game = newRound();
  114. const holder = game.addPlayer('Volé', 'generic');
  115. const thief = game.addPlayer('Voleur', 'castor');
  116. game.teleport(holder.id, 600, 420);
  117. game.teleport(thief.id, 560, 420);
  118. run(game, 60);
  119. const h = me(game, holder.id);
  120. cherryOf(game).body.setPosition({ x: h.x / PPM, y: h.y / PPM });
  121. run(game, 2);
  122. expect(game.roundState().cherry!.holder).toBe(holder.id);
  123. // Bite: the cherry pops out; put it in the thief's mouth right away.
  124. const t = me(game, thief.id);
  125. game.setInput(thief.id, { buttons: Button.Attack, aimX: Math.round(h.x), aimY: Math.round(h.y) });
  126. run(game, 1);
  127. game.setInput(thief.id, { buttons: 0, aimX: 0, aimY: 0 });
  128. expect(game.roundState().cherry!.holder).toBe(0);
  129. cherryOf(game).body.setPosition({ x: t.x / PPM, y: t.y / PPM });
  130. cherryOf(game).body.setLinearVelocity({ x: 0, y: 0 });
  131. const fx = run(game, 2);
  132. expect(fx).toContainEqual(expect.objectContaining({ kind: 'stolen', id: thief.id, from: holder.id, bonus: CHERRY_STEAL_BONUS }));
  133. expect(me(game, thief.id).score).toBeGreaterThanOrEqual(CHERRY_STEAL_BONUS);
  134. });
  135. });
  136. describe('cartoon hits', () => {
  137. it('a small falling chunk flattens a player into a pancake instead of killing them', () => {
  138. const game = newRound();
  139. const { id } = game.addPlayer('Crêpe', 'generic');
  140. game.teleport(id, 400, 680); // bottom tier, open sky above
  141. run(game, 30);
  142. // A small block of biscuit (under CRUSH_KILL_PIXELS) dropped from high above.
  143. const { width, data } = game.terrain;
  144. const side = Math.floor(Math.sqrt(CRUSH_KILL_PIXELS / 2));
  145. // Built hanging in the air: it is not anchored, so the next detach search drops it.
  146. 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;
  147. game.terrain.markDirty({ x0: 300, y0: 280, x1: 500, y1: 300 + side + 10 });
  148. game.queueOp({ kind: 'carve', x: 400, y: 300 + side, r: 2, power: 99 }); // triggers the detach search there
  149. let crushedFlat = false;
  150. for (let i = 0; i < 2 * TICK_RATE && !crushedFlat; i++) {
  151. const res = game.step();
  152. if (res.crushed.some((c) => c.id === id && c.flat)) crushedFlat = true;
  153. }
  154. expect(crushedFlat).toBe(true);
  155. const p = me(game, id);
  156. expect(p.anim & Anim.Flat).toBeTruthy();
  157. expect(p.anim & Anim.Dead).toBe(0);
  158. });
  159. it('a player knocked into a wall goes SPLAF', () => {
  160. const game = newRound();
  161. const cat = game.addPlayer('Chat', 'chat');
  162. const victim = game.addPlayer('Victime', 'generic');
  163. game.teleport(cat.id, 420, 680); // bottom tier, middle tier's wall at x = 520
  164. game.teleport(victim.id, 470, 680);
  165. run(game, 30);
  166. const c = me(game, cat.id);
  167. game.setInput(cat.id, { buttons: Button.Special, aimX: 600, aimY: Math.round(c.y) });
  168. run(game, 1);
  169. game.setInput(cat.id, { buttons: 0, aimX: 600, aimY: Math.round(c.y) });
  170. const fx = run(game, 30);
  171. expect(fx).toContainEqual(expect.objectContaining({ kind: 'splat', id: victim.id }));
  172. });
  173. });