rules.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import { describe, expect, it } from 'vitest';
  2. import { loadCharacterStats, loadMap } from '../src/server/assets';
  3. import { Game } from '../src/server/game';
  4. import { MatchStats } from '../src/server/stats';
  5. import { Anim, PLAYABLE_CHARACTER_IDS } from '../src/shared/characters';
  6. 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';
  7. import { Button } from '../src/shared/input';
  8. import type { FxEvent } from '../src/shared/protocol';
  9. import { ASSETS, realMaterials } from './helpers';
  10. const materials = realMaterials();
  11. function newRound(duration = 180, seed = 1): 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. describe('custard and the spoon', () => {
  22. it('rises during a round, faster in the final rush, and stays put during the warm-up', () => {
  23. const round = newRound(40);
  24. const start = round.custardLevel;
  25. run(round, 5 * TICK_RATE);
  26. const slow = start - round.custardLevel;
  27. expect(slow).toBeCloseTo(round.map.custard.speed * 5, 0);
  28. run(round, (40 - FINAL_RUSH_S) * TICK_RATE); // into the final rush
  29. const before = round.custardLevel;
  30. run(round, 5 * TICK_RATE);
  31. expect(before - round.custardLevel).toBeCloseTo(round.map.custard.finalRushSpeed * 5, 0);
  32. expect(round.roundState().finalRush).toBe(true);
  33. const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
  34. const warmup = new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
  35. run(warmup, 5 * TICK_RATE);
  36. expect(warmup.custardLevel).toBe(definition.custard.startY);
  37. expect(warmup.roundState()).toMatchObject({ timeLeft: null, cherry: null });
  38. });
  39. it('a player falling in is out for 5 s, then the spoon launches them back from open air', () => {
  40. const game = newRound();
  41. const { id } = game.addPlayer('Plouf', 'generic');
  42. game.teleport(id, 100, 900); // left of the cake: nothing below but custard
  43. const fx = run(game, TICK_RATE);
  44. expect(fx).toContainEqual(expect.objectContaining({ kind: 'splash', id }));
  45. expect(me(game, id).anim & Anim.Dead).toBeTruthy();
  46. expect(game.stats.get(id, 'custard')).toBe(1);
  47. const later = run(game, RESPAWN_DELAY_S * TICK_RATE);
  48. const spoon = later.find((e) => e.kind === 'spoon');
  49. expect(spoon).toBeDefined();
  50. const p = me(game, id);
  51. expect(p.anim & Anim.Dead).toBe(0);
  52. expect(p.y).toBeLessThan(game.custardLevel);
  53. // ...and lands on the cake instead of falling straight back in.
  54. const flight = run(game, 3 * TICK_RATE);
  55. expect(flight.filter((e) => e.kind === 'splash' && e.id === id)).toEqual([]);
  56. expect(me(game, id).grounded).toBe(true);
  57. });
  58. it('the spoon lands players back on the cake whatever spawn it rolls (several seeds)', () => {
  59. for (const seed of [1, 2, 3, 4, 5, 6]) {
  60. const game = newRound(180, seed);
  61. const { id } = game.addPlayer('Plouf', 'generic');
  62. game.teleport(id, 100, 900);
  63. run(game, (RESPAWN_DELAY_S + 0.5) * TICK_RATE);
  64. const fx = run(game, 3 * TICK_RATE);
  65. expect(fx.filter((e) => e.kind === 'splash' && e.id === id), `seed ${seed}`).toEqual([]);
  66. }
  67. });
  68. it('the spoon finds the last little island of cake', () => {
  69. const game = newRound();
  70. // Wipe the cake, except for the plate and a 120 px block on the far right.
  71. const { width, height, data } = game.terrain;
  72. for (let y = 0; y < height; y++) {
  73. for (let x = 0; x < width; x++) {
  74. const i = y * width + x;
  75. // (The raised ends of the plate go too: they would be ledges as well.)
  76. const plate = data[i] === 255 && x >= 220 && x < 1830;
  77. if (!plate && !(x >= 1600 && x < 1720 && y >= 820 && data[i] !== 255)) data[i] = 0;
  78. }
  79. }
  80. game.terrain.markDirty({ x0: 0, y0: 0, x1: width, y1: height });
  81. const { id } = game.addPlayer('Plouf', 'generic');
  82. game.teleport(id, 60, 900); // left of the plate: nothing below but custard
  83. run(game, (RESPAWN_DELAY_S + 0.5) * TICK_RATE);
  84. const fx = run(game, 3 * TICK_RATE);
  85. expect(fx.filter((e) => e.kind === 'splash' && e.id === id)).toEqual([]);
  86. const p = me(game, id);
  87. expect(p.grounded).toBe(true);
  88. expect(p.x).toBeGreaterThan(1590);
  89. expect(p.x).toBeLessThan(1730);
  90. });
  91. it('the round ends when the timer runs out', () => {
  92. const game = newRound(2);
  93. run(game, 2 * TICK_RATE - 1);
  94. expect(game.roundOver).toBe(false);
  95. run(game, 2);
  96. expect(game.roundOver).toBe(true);
  97. expect(game.endReason).toBe('time');
  98. });
  99. it('the round ends early when almost no cake is left above the custard', () => {
  100. const game = newRound();
  101. run(game, 2 * TICK_RATE);
  102. expect(game.roundOver).toBe(false);
  103. const { data } = game.terrain;
  104. let kept = 0;
  105. for (let i = 0; i < data.length; i++) {
  106. if (data[i] === 0 || data[i] === 255) continue;
  107. if (kept < MIN_CAKE_PIXELS / 2) kept++;
  108. else data[i] = 0;
  109. }
  110. run(game, 2 * TICK_RATE);
  111. expect(game.roundOver).toBe(true);
  112. expect(game.endReason).toBe('noCake');
  113. });
  114. });
  115. describe('fair fights', () => {
  116. it('a stun cannot be chained: after one, the player is immune for a moment', () => {
  117. const game = newRound();
  118. const mamie = game.addPlayer('Mamie', 'mamie');
  119. const victim = game.addPlayer('Victime', 'generic');
  120. run(game, 90);
  121. let stunnedTicks = 0;
  122. let longest = 0;
  123. let streak = 0;
  124. // Mamie jabs as fast as she can for 4 s, the victim held right in front of her.
  125. for (let i = 0; i < 4 * TICK_RATE; i++) {
  126. const m = me(game, mamie.id);
  127. game.teleport(victim.id, m.x + 36, m.y);
  128. game.setInput(mamie.id, { buttons: Button.Attack, aimX: Math.round(m.x + 36), aimY: Math.round(m.y) });
  129. game.step();
  130. const stunned = (me(game, victim.id).anim & Anim.Stunned) !== 0;
  131. if (stunned) stunnedTicks++;
  132. streak = stunned ? streak + 1 : 0;
  133. longest = Math.max(longest, streak);
  134. }
  135. const stun = loadCharacterStats(ASSETS, 'mamie').params.needleStun;
  136. expect(longest).toBeLessThanOrEqual(Math.ceil(stun * TICK_RATE) + 1);
  137. // Free for at least the guard time after each stun.
  138. expect(stunnedTicks / (4 * TICK_RATE)).toBeLessThan(stun / (stun + STUN_GUARD_S) + 0.1);
  139. });
  140. it('a stuck player can give up: into the custard, back with the spoon', () => {
  141. const game = newRound();
  142. const { id } = game.addPlayer('Coincé', 'generic');
  143. run(game, 60);
  144. game.setInput(id, { buttons: Button.Suicide, aimX: 0, aimY: 0 });
  145. const fx = run(game, 1);
  146. game.setInput(id, { buttons: 0, aimX: 0, aimY: 0 });
  147. expect(fx).toContainEqual(expect.objectContaining({ kind: 'splash', id }));
  148. expect(me(game, id).anim & Anim.Dead).toBeTruthy();
  149. run(game, (RESPAWN_DELAY_S + 0.5) * TICK_RATE);
  150. expect(me(game, id).anim & Anim.Dead).toBe(0);
  151. });
  152. });
  153. describe('the cherry', () => {
  154. it('is picked up by touching it and scores 1 point per second', () => {
  155. const game = newRound();
  156. const { id } = game.addPlayer('Gourmand', 'generic');
  157. const [cx, cy] = game.map.cherrySpawn;
  158. run(game, 30); // let the cherry settle on the top tier
  159. const cherry = game.roundState().cherry!;
  160. game.teleport(id, cherry.x, cherry.y - 10);
  161. const fx = run(game, 10);
  162. expect(fx).toContainEqual(expect.objectContaining({ kind: 'pickup', id, item: 'cherry' }));
  163. expect(game.roundState().cherry!.holder).toBe(id);
  164. run(game, 3 * TICK_RATE);
  165. expect(me(game, id).score).toBeGreaterThanOrEqual(3);
  166. expect([cx, cy]).toEqual(game.map.cherrySpawn);
  167. });
  168. it('a hit holder drops it, and cannot grab it back right away', () => {
  169. const game = newRound();
  170. const holder = game.addPlayer('Porteur', 'generic');
  171. const biter = game.addPlayer('Castor', 'castor');
  172. run(game, 30);
  173. const cherry = game.roundState().cherry!;
  174. game.teleport(holder.id, cherry.x, cherry.y - 10);
  175. run(game, 10);
  176. expect(game.roundState().cherry!.holder).toBe(holder.id);
  177. const h = me(game, holder.id);
  178. game.teleport(biter.id, h.x - 45, h.y);
  179. game.setInput(biter.id, { buttons: Button.Attack, aimX: Math.round(h.x), aimY: Math.round(h.y) });
  180. const fx = run(game, 2);
  181. expect(fx).toContainEqual(expect.objectContaining({ kind: 'cherryDrop', id: holder.id }));
  182. expect(game.roundState().cherry!.holder).not.toBe(holder.id);
  183. expect(game.stats.get(biter.id, 'hits')).toBeGreaterThanOrEqual(1);
  184. // Still blocked for the holder for a moment.
  185. game.setInput(biter.id, { buttons: 0, aimX: 0, aimY: 0 });
  186. run(game, Math.floor(CHERRY_REGRAB_DELAY * TICK_RATE * 0.5));
  187. expect(game.roundState().cherry!.holder).not.toBe(holder.id);
  188. });
  189. });
  190. describe('items', () => {
  191. it('a candle is picked up, thrown with Use, and explodes: carves the cake and blows players away', () => {
  192. const game = newRound();
  193. const { id } = game.addPlayer('Artificier', 'generic');
  194. run(game, 90);
  195. const p = me(game, id);
  196. game.spawnItem('candle', p.x, p.y - 30);
  197. const fx = run(game, 20);
  198. expect(fx).toContainEqual(expect.objectContaining({ kind: 'pickup', id, item: 'candle' }));
  199. expect(me(game, id).item).toBe('candle');
  200. const eventsBefore = game.events.length;
  201. game.setInput(id, { buttons: Button.Use, aimX: Math.round(p.x + 60), aimY: Math.round(p.y + 80) });
  202. const after = run(game, 2 * TICK_RATE);
  203. expect(me(game, id).item).toBeNull();
  204. expect(after).toContainEqual(expect.objectContaining({ kind: 'explosion' }));
  205. expect(game.events.length).toBeGreaterThan(eventsBefore);
  206. expect(game.stats.get(id, 'candles')).toBe(1);
  207. });
  208. it('smarties boost the speed for a while', () => {
  209. const game = newRound();
  210. const { id } = game.addPlayer('Speedy', 'generic');
  211. run(game, 90);
  212. const p = me(game, id);
  213. game.spawnItem('smarties', p.x, p.y - 30);
  214. run(game, 20);
  215. expect(me(game, id).anim & Anim.Boosted).toBeTruthy();
  216. run(game, SMARTIES_DURATION_S * TICK_RATE);
  217. expect(me(game, id).anim & Anim.Boosted).toBe(0);
  218. });
  219. it('the electric whisk jolts the others and shakes the jelly', () => {
  220. const game = newRound();
  221. const a = game.addPlayer('Fouet', 'generic');
  222. const b = game.addPlayer('Victime', 'generic');
  223. run(game, 90);
  224. const p = me(game, a.id);
  225. const eventsBefore = game.events.length;
  226. game.spawnItem('whisk', p.x, p.y - 30);
  227. let whisked = false;
  228. for (let i = 0; i < 30 && !whisked; i++) whisked = game.step().fx.some((e) => e.kind === 'whisk' && e.id === a.id);
  229. expect(whisked).toBe(true);
  230. // Same tick: the other player is jolted and stunned.
  231. expect(me(game, b.id).anim & Anim.Stunned).toBeTruthy();
  232. run(game, 2);
  233. expect(game.events.length).toBeGreaterThan(eventsBefore + 5);
  234. expect(game.stats.get(a.id, 'eaten')).toBeGreaterThan(0);
  235. });
  236. it('items fall from the sky on their own during a round (seeded)', () => {
  237. const a = newRound(180, 7);
  238. const b = newRound(180, 7);
  239. // The first item drops within map.items.interval[1] seconds; both games drop the same ones.
  240. let seen = 0;
  241. for (let i = 0; i < a.map.items.interval[1] * TICK_RATE + 2; i++) {
  242. a.step();
  243. b.step();
  244. seen = Math.max(seen, a.itemStates().length);
  245. }
  246. expect(seen).toBeGreaterThan(0);
  247. expect(a.itemStates()).toEqual(b.itemStates());
  248. });
  249. });
  250. describe('trophies', () => {
  251. it('go to the best player of each stat, only if they did it', () => {
  252. const stats = new MatchStats();
  253. stats.add(1, 'eaten', 5000);
  254. stats.add(2, 'eaten', 300);
  255. stats.add(2, 'custard', 3);
  256. stats.add(3, 'crushed', 1);
  257. stats.add(3, 'hitTaken', 4);
  258. const t = stats.trophies([1, 2, 3]);
  259. expect(t).toContainEqual({ id: 'glutton', playerId: 1, value: 5000 });
  260. expect(t).toContainEqual({ id: 'kamikaze', playerId: 2, value: 3 });
  261. expect(t).toContainEqual({ id: 'notMyFault', playerId: 3, value: 5 });
  262. expect(t.find((x) => x.id === 'pyromaniac')).toBeUndefined();
  263. });
  264. });
  265. describe('getting up', () => {
  266. /** Holds toward the wall and taps jump, like a player climbing; returns the highest y reached. */
  267. function climb(game: Game, id: number, ticks: number): number {
  268. let best = Infinity;
  269. for (let i = 0; i < ticks; i++) {
  270. const jump = i % 12 < 6 ? Button.Jump : 0;
  271. game.setInput(id, { buttons: Button.Right | jump, aimX: 2000, aimY: 0 });
  272. game.step();
  273. best = Math.min(best, me(game, id).y);
  274. }
  275. return best;
  276. }
  277. it('every character can climb a tier wall by wall-jumping', () => {
  278. for (const character of PLAYABLE_CHARACTER_IDS) {
  279. const game = newRound();
  280. const { id } = game.addPlayer('Grimpeur', character);
  281. game.teleport(id, 470, 670); // bottom tier, against the middle tier's wall (x = 520, 230 px high)
  282. run(game, 30);
  283. expect(climb(game, id, 6 * TICK_RATE), character).toBeLessThan(470);
  284. }
  285. });
  286. it('the cherry is heavy: its holder jumps lower', () => {
  287. const jumpHeight = (holding: boolean): number => {
  288. const game = newRound();
  289. const { id } = game.addPlayer('Sauteur', 'generic');
  290. game.teleport(id, 400, 680);
  291. run(game, 30);
  292. if (holding) {
  293. const cherry = (game as unknown as { cherry: { body: { setPosition(p: { x: number; y: number }): void } } }).cherry;
  294. const p = me(game, id);
  295. cherry.body.setPosition({ x: p.x / 32, y: p.y / 32 });
  296. run(game, 2);
  297. expect(game.roundState().cherry!.holder).toBe(id);
  298. }
  299. const y0 = me(game, id).y;
  300. game.setInput(id, { buttons: Button.Jump, aimX: 0, aimY: 0 });
  301. let top = y0;
  302. for (let i = 0; i < TICK_RATE; i++) {
  303. game.step();
  304. top = Math.min(top, me(game, id).y);
  305. }
  306. return y0 - top;
  307. };
  308. expect(jumpHeight(true)).toBeLessThan(jumpHeight(false) * 0.75);
  309. });
  310. it('a cherry that falls in the custard comes back on the cake, not always at the top', () => {
  311. const tops = new Set<number>();
  312. for (const seed of [1, 2, 3, 4]) {
  313. const game = newRound(180, seed);
  314. const cherry = (game as unknown as { cherry: { body: { setPosition(p: { x: number; y: number }): void } } }).cherry;
  315. cherry.body.setPosition({ x: 100 / 32, y: 1010 / 32 });
  316. run(game, 2);
  317. const c = game.roundState().cherry!;
  318. expect(c.y).toBeLessThan(game.custardLevel);
  319. run(game, TICK_RATE); // it lands on something
  320. expect(game.roundState().cherry!.y).toBeLessThan(game.custardLevel - 10);
  321. tops.add(Math.round(c.y / 100));
  322. }
  323. expect(tops.size).toBeGreaterThan(1);
  324. });
  325. });