characters.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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, CHARACTER_IDS } from '../src/shared/characters';
  5. import { MATERIAL_WOOL, TICK_RATE } from '../src/shared/constants';
  6. import { Button } from '../src/shared/input';
  7. import type { FxEvent } from '../src/shared/protocol';
  8. import { ASSETS, realMaterials } from './helpers';
  9. const materials = realMaterials();
  10. function newGame(): Game {
  11. const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
  12. return new Game({ map: definition, terrain, materials, seed: 5, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
  13. }
  14. function run(game: Game, ticks: number): FxEvent[] {
  15. const fx: FxEvent[] = [];
  16. for (let i = 0; i < ticks; i++) fx.push(...game.step().fx);
  17. return fx;
  18. }
  19. const me = (game: Game, id: number) => game.snapshot().find((p) => p.id === id)!;
  20. const countWool = (game: Game): number => game.terrain.data.reduce((n, m) => n + (m === MATERIAL_WOOL ? 1 : 0), 0);
  21. /** Presses `buttons` aimed at (x, y) for one tick, then releases. */
  22. function press(game: Game, id: number, buttons: number, x: number, y: number): FxEvent[] {
  23. game.setInput(id, { buttons, aimX: Math.round(x), aimY: Math.round(y) });
  24. const fx = run(game, 1);
  25. game.setInput(id, { buttons: 0, aimX: Math.round(x), aimY: Math.round(y) });
  26. return fx;
  27. }
  28. describe('roster', () => {
  29. it('every character has data that loads', () => {
  30. expect(CHARACTER_IDS).toEqual(['castor', 'mamie', 'pyromanchot', 'gege', 'pompier', 'prout', 'chat', 'magicien', 'generic']);
  31. for (const id of CHARACTER_IDS) expect(loadCharacterStats(ASSETS, id).id).toBe(id);
  32. });
  33. });
  34. describe('Mamie Tricot', () => {
  35. it('her yarn ball lays a taut wool bridge that holds: nothing falls, nobody is hurt', () => {
  36. const game = newGame();
  37. const { id } = game.addPlayer('Mamie', 'mamie');
  38. game.teleport(id, 640, 440); // middle tier, left of the top tier wall (x ≈ 780)
  39. run(game, 60);
  40. const m = me(game, id);
  41. let detached = 0;
  42. let crushed = 0;
  43. game.setInput(id, { buttons: Button.Special, aimX: 800, aimY: Math.round(m.y - 40) });
  44. for (let i = 0; i < 4 * TICK_RATE; i++) {
  45. const r = game.step();
  46. if (i === 0) game.setInput(id, { buttons: 0, aimX: 800, aimY: Math.round(m.y - 40) });
  47. detached += r.terrain.filter((e) => e.ev.kind === 'detach').length;
  48. crushed += r.crushed.length;
  49. }
  50. expect(countWool(game)).toBeGreaterThan(300);
  51. expect(detached).toBe(0);
  52. expect(crushed).toBe(0);
  53. expect(me(game, id).anim & Anim.Dead).toBe(0);
  54. });
  55. it('the thread follows the ball in flight, tied under her feet', () => {
  56. const game = newGame();
  57. const { id } = game.addPlayer('Mamie', 'mamie');
  58. game.teleport(id, 640, 440);
  59. run(game, 60);
  60. const m = me(game, id);
  61. press(game, id, Button.Special, 800, m.y - 40);
  62. const ball = game.itemStates().find((it) => it.kind === 'yarn')!;
  63. expect(ball.thread).toBeDefined();
  64. const [ax, ay] = ball.thread!;
  65. expect(Math.abs(ax - m.x)).toBeLessThan(20);
  66. expect(ay).toBeGreaterThan(m.y); // on the ground under her
  67. expect(game.terrain.isSolid(Math.round(ax), Math.round(ay))).toBe(true);
  68. });
  69. it('a yarn ball thrown at her own feet never buries or kills her', () => {
  70. const game = newGame();
  71. const { id } = game.addPlayer('Mamie', 'mamie');
  72. game.teleport(id, 640, 440);
  73. run(game, 60);
  74. for (const [ax, ay] of [[0, 60], [0, -200], [40, 20], [-40, 20]]) {
  75. const m = me(game, id);
  76. press(game, id, Button.Special, m.x + ax, m.y + ay);
  77. run(game, 3 * TICK_RATE);
  78. const after = me(game, id);
  79. expect(after.anim & Anim.Dead).toBe(0);
  80. // Not stuck inside wool: no solid pixel at her centre.
  81. expect(game.terrain.isSolid(Math.round(after.x), Math.round(after.y))).toBe(false);
  82. }
  83. });
  84. it('her needles barely push but stun for a long time', () => {
  85. const game = newGame();
  86. const mamie = game.addPlayer('Mamie', 'mamie');
  87. const victim = game.addPlayer('Victime', 'generic');
  88. run(game, 90);
  89. const m = me(game, mamie.id);
  90. game.teleport(victim.id, m.x + 40, m.y);
  91. press(game, mamie.id, Button.Attack, m.x + 40, m.y);
  92. const v = me(game, victim.id);
  93. expect(v.anim & Anim.Stunned).toBeTruthy();
  94. expect(Math.hypot(v.vx, v.vy)).toBeLessThan(400);
  95. run(game, Math.round(0.6 * TICK_RATE));
  96. expect(me(game, victim.id).anim & Anim.Stunned).toBeTruthy();
  97. });
  98. it('her wool burns: an explosion nearby consumes it', () => {
  99. const game = newGame();
  100. const mamie = game.addPlayer('Mamie', 'mamie');
  101. const pyro = game.addPlayer('Pyro', 'pyromanchot');
  102. game.teleport(mamie.id, 640, 440);
  103. run(game, 60);
  104. const m = me(game, mamie.id);
  105. press(game, mamie.id, Button.Special, 800, m.y - 40);
  106. run(game, 2 * TICK_RATE);
  107. const before = countWool(game);
  108. expect(before).toBeGreaterThan(0);
  109. // A firecracker thrown into the wool.
  110. game.teleport(pyro.id, 700, 300);
  111. run(game, 2);
  112. const fx = press(game, pyro.id, Button.Attack, 760, m.y - 30).concat(run(game, TICK_RATE));
  113. expect(fx.some((e) => e.kind === 'explosion')).toBe(true);
  114. run(game, 2);
  115. expect(countWool(game)).toBeLessThan(before);
  116. });
  117. });
  118. describe('Pyromanchot', () => {
  119. it('throws firecrackers that explode away from him, and every third one backfires', () => {
  120. const game = newGame();
  121. const { id } = game.addPlayer('Pyro', 'pyromanchot');
  122. run(game, 90);
  123. const explosions: { x: number; y: number }[] = [];
  124. for (let shot = 0; shot < 3; shot++) {
  125. const p = me(game, id);
  126. const fx = press(game, id, Button.Attack, p.x + 300, p.y - 100).concat(run(game, TICK_RATE));
  127. for (const e of fx) if (e.kind === 'explosion') explosions.push({ x: e.x - p.x, y: e.y - p.y });
  128. run(game, Math.round(0.5 * TICK_RATE));
  129. }
  130. expect(explosions).toHaveLength(3);
  131. // The first two flew off; the third went off right next to him.
  132. expect(Math.hypot(explosions[0].x, explosions[0].y)).toBeGreaterThan(60);
  133. expect(Math.hypot(explosions[1].x, explosions[1].y)).toBeGreaterThan(60);
  134. expect(Math.hypot(explosions[2].x, explosions[2].y)).toBeLessThan(40);
  135. });
  136. it('a backfire stuns him', () => {
  137. const game = newGame();
  138. const { id } = game.addPlayer('Pyro', 'pyromanchot');
  139. run(game, 90);
  140. for (let shot = 0; shot < 3; shot++) {
  141. const p = me(game, id);
  142. press(game, id, Button.Attack, p.x + 300, p.y - 100);
  143. if (shot < 2) run(game, TICK_RATE);
  144. }
  145. expect(me(game, id).anim & Anim.Stunned).toBeTruthy();
  146. });
  147. it('belly slide: fast, then an explosion at the end that spares him', () => {
  148. const game = newGame();
  149. const pyro = game.addPlayer('Pyro', 'pyromanchot');
  150. const victim = game.addPlayer('Victime', 'generic');
  151. game.teleport(pyro.id, 900, 680); // bottom tier, lots of room to the right
  152. game.teleport(victim.id, 1500, 680);
  153. run(game, 60);
  154. const start = me(game, pyro.id);
  155. press(game, pyro.id, Button.Special, start.x + 500, start.y);
  156. expect(me(game, pyro.id).anim & Anim.Special).toBeTruthy();
  157. const fx = run(game, TICK_RATE);
  158. const end = me(game, pyro.id);
  159. expect(end.x - start.x).toBeGreaterThan(200);
  160. expect(fx.some((e) => e.kind === 'explosion')).toBe(true);
  161. expect(end.anim & Anim.Stunned).toBe(0);
  162. });
  163. });
  164. describe('Gégé le Ver', () => {
  165. it('swims down into the cake, then bursts out under a player and sends them flying', () => {
  166. const game = newGame();
  167. const gege = game.addPlayer('Gégé', 'gege');
  168. const victim = game.addPlayer('Victime', 'generic');
  169. game.teleport(gege.id, 600, 420); // middle tier top
  170. game.teleport(victim.id, 730, 420);
  171. run(game, 60);
  172. // Dive.
  173. for (let i = 0; i < 40; i++) {
  174. const g = me(game, gege.id);
  175. game.setInput(gege.id, { buttons: Button.Special, aimX: Math.round(g.x), aimY: Math.round(g.y + 200) });
  176. game.step();
  177. }
  178. expect(me(game, gege.id).anim & Anim.Underground).toBeTruthy();
  179. // Tunnel toward the victim, under them, then let go: he bursts out.
  180. for (let i = 0; i < 200; i++) {
  181. const g = me(game, gege.id);
  182. const v = me(game, victim.id);
  183. const under = Math.abs(g.x - v.x) < 20;
  184. game.setInput(gege.id, { buttons: Button.Special, aimX: Math.round(v.x), aimY: Math.round(under ? v.y : 505) });
  185. game.step();
  186. if (under && g.y - v.y < 70) break;
  187. }
  188. game.setInput(gege.id, { buttons: 0, aimX: 0, aimY: 0 });
  189. const fx = run(game, 2);
  190. expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', id: gege.id, cue: 'emerge' }));
  191. const v = me(game, victim.id);
  192. expect(v.anim & Anim.Stunned).toBeTruthy();
  193. expect(v.vy).toBeLessThan(-300);
  194. });
  195. });
  196. describe('Bébé Pompier', () => {
  197. it('the water jet pushes a player away and the recoil sends him backwards', () => {
  198. const game = newGame();
  199. const baby = game.addPlayer('Bébé', 'pompier');
  200. const victim = game.addPlayer('Victime', 'generic');
  201. game.teleport(baby.id, 600, 420);
  202. run(game, 60);
  203. const b = me(game, baby.id);
  204. game.teleport(victim.id, b.x + 80, b.y);
  205. run(game, 1);
  206. const fx = press(game, baby.id, Button.Attack, b.x + 200, b.y);
  207. expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', cue: 'water' }));
  208. expect(me(game, victim.id).vx).toBeGreaterThan(200);
  209. expect(me(game, baby.id).vx).toBeLessThan(-200);
  210. });
  211. it('soaking turns sponge into soaked sponge, which then sags on its own', () => {
  212. const game = newGame();
  213. const baby = game.addPlayer('Bébé', 'pompier');
  214. game.teleport(baby.id, 600, 420);
  215. run(game, 60);
  216. const b = me(game, baby.id);
  217. press(game, baby.id, Button.Special, b.x + 60, b.y + 120);
  218. const soaked = (): number => game.terrain.data.reduce((n, m) => n + (m === 8 ? 1 : 0), 0);
  219. const after = soaked();
  220. expect(after).toBeGreaterThan(1000);
  221. run(game, 4 * TICK_RATE);
  222. expect(soaked()).toBeLessThan(after); // crumbling
  223. });
  224. });
  225. describe('Captain Prout', () => {
  226. it('each thrust lifts him, is heard by all, and leaves a cloud that poisons players inside', () => {
  227. const game = newGame();
  228. const prout = game.addPlayer('Prout', 'prout');
  229. const victim = game.addPlayer('Victime', 'generic');
  230. game.teleport(prout.id, 600, 420);
  231. run(game, 60);
  232. const p0 = me(game, prout.id);
  233. const fx = press(game, prout.id, Button.Special, p0.x, p0.y - 100);
  234. expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', id: prout.id, cue: 'prout' }));
  235. expect(me(game, prout.id).vy).toBeLessThan(-300);
  236. expect(game.roundState().clouds.length).toBe(1);
  237. const [cx, cy] = game.roundState().clouds[0];
  238. game.teleport(victim.id, cx, cy);
  239. run(game, 2);
  240. expect(me(game, victim.id).anim & Anim.Poisoned).toBeTruthy();
  241. run(game, 1);
  242. game.teleport(prout.id, cx, cy);
  243. run(game, 2);
  244. expect(me(game, prout.id).anim & Anim.Poisoned).toBeFalsy(); // immune to his own clouds
  245. });
  246. });
  247. describe('Le Chat', () => {
  248. it('shoves a stunned player hard', () => {
  249. const game = newGame();
  250. const cat = game.addPlayer('Chat', 'chat');
  251. const a = game.addPlayer('Assommé', 'generic');
  252. game.teleport(cat.id, 600, 420);
  253. run(game, 60);
  254. const c = me(game, cat.id);
  255. game.teleport(a.id, c.x + 50, c.y);
  256. run(game, 1);
  257. press(game, cat.id, Button.Attack, c.x + 50, c.y); // paw: stuns
  258. expect(me(game, a.id).anim & Anim.Stunned).toBeTruthy();
  259. const now = me(game, cat.id);
  260. game.teleport(a.id, now.x + 55 * now.facing, now.y);
  261. press(game, cat.id, Button.Special, now.x + 200 * now.facing, now.y);
  262. expect(Math.abs(me(game, a.id).vx)).toBeGreaterThan(400);
  263. });
  264. it('stops to lick his paw now and then, ignoring input', () => {
  265. const game = newGame();
  266. const cat = game.addPlayer('Chat', 'chat');
  267. run(game, 60);
  268. let licked = false;
  269. for (let i = 0; i < 16 * TICK_RATE && !licked; i++) licked = game.step().fx.some((e) => e.kind === 'cue' && e.cue === 'lick');
  270. expect(licked).toBe(true);
  271. expect(me(game, cat.id).anim & Anim.Busy).toBeTruthy();
  272. const x0 = me(game, cat.id).x;
  273. game.setInput(cat.id, { buttons: Button.Right, aimX: 0, aimY: 0 });
  274. run(game, 20);
  275. expect(Math.abs(me(game, cat.id).x - x0)).toBeLessThan(5);
  276. });
  277. });
  278. describe('Magicien Raté', () => {
  279. function castMany(leading: boolean, casts: number): Record<string, number> {
  280. const game = newGame();
  281. const mage = game.addPlayer('Mage', 'magicien');
  282. const other = game.addPlayer('Autre', 'generic');
  283. game.teleport(mage.id, 600, 420);
  284. game.teleport(other.id, 1400, 420);
  285. run(game, 60);
  286. // Make him the leader (or last) by giving round points.
  287. const players = (game as unknown as { players: Map<number, { score: number }> }).players;
  288. players.get(mage.id)!.score = leading ? 50 : 0;
  289. players.get(other.id)!.score = leading ? 0 : 50;
  290. const counts: Record<string, number> = {};
  291. for (let i = 0; i < casts; i++) {
  292. const m = me(game, mage.id);
  293. const fx = press(game, mage.id, Button.Special, m.x + 100, m.y - 50);
  294. for (const e of fx) if (e.kind === 'cue' && e.cue.startsWith('spell-')) counts[e.cue] = (counts[e.cue] ?? 0) + 1;
  295. run(game, Math.ceil(2.3 * TICK_RATE));
  296. }
  297. return counts;
  298. }
  299. it('casts every kind of spell, and fizzles far more often when leading', () => {
  300. const last = castMany(false, 40);
  301. expect(Object.keys(last).sort()).toEqual(['spell-jelly', 'spell-pfft', 'spell-rabbit', 'spell-swap']);
  302. const lead = castMany(true, 40);
  303. expect(lead['spell-pfft'] ?? 0).toBeGreaterThan((last['spell-pfft'] ?? 0) * 2);
  304. });
  305. it('the giant rabbit appears in the air and falls (an unanchored sponge chunk)', () => {
  306. const game = newGame();
  307. const mage = game.addPlayer('Mage', 'magicien');
  308. game.teleport(mage.id, 600, 420);
  309. run(game, 60);
  310. for (let i = 0; i < 60; i++) {
  311. const m = me(game, mage.id);
  312. game.setInput(mage.id, { buttons: Button.Special, aimX: Math.round(m.x + 100), aimY: Math.round(m.y - 50) });
  313. const r = game.step();
  314. game.setInput(mage.id, { buttons: 0, aimX: 0, aimY: 0 });
  315. if (!r.fx.some((e) => e.kind === 'cue' && e.cue === 'spell-rabbit')) {
  316. run(game, Math.ceil(2.3 * TICK_RATE));
  317. continue;
  318. }
  319. // The rabbit is filled in this very tick, and comes loose in the same detach pass.
  320. expect(r.terrain.some((e) => e.ev.kind === 'detach' && !e.ev.debris)).toBe(true);
  321. return;
  322. }
  323. throw new Error('no rabbit in 60 casts');
  324. });
  325. });