game.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  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 { Button } from '../src/shared/input';
  6. import { applyTerrainEvent } from '../src/shared/terrain/events';
  7. import { ASSETS, realMaterials } from './helpers';
  8. function newGame(): Game {
  9. const materials = realMaterials();
  10. const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
  11. return new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
  12. }
  13. describe('server game', () => {
  14. it('a spawned player lands on the cake and stays on it', () => {
  15. const game = newGame();
  16. const { id } = game.addPlayer('A', 'generic');
  17. for (let i = 0; i < 180; i++) game.step();
  18. const [p] = game.snapshot();
  19. expect(p.id).toBe(id);
  20. expect(p.grounded).toBe(true);
  21. expect(p.y).toBeLessThan(game.terrain.height);
  22. });
  23. it('attacking carves the terrain and logs sequenced ops', () => {
  24. const game = newGame();
  25. const { id } = game.addPlayer('A', 'generic');
  26. for (let i = 0; i < 120; i++) game.step();
  27. const [p] = game.snapshot();
  28. const before = game.terrain.hash();
  29. game.setInput(id, { buttons: Button.Attack, aimX: Math.round(p.x), aimY: Math.round(p.y + 40) });
  30. const applied = game.step().terrain;
  31. expect(applied[0]).toMatchObject({ seq: 1, ev: { kind: 'op' } });
  32. expect(game.terrain.hash()).not.toBe(before);
  33. expect(game.events.length).toBeGreaterThanOrEqual(1);
  34. });
  35. });
  36. describe('Castor Glouton', () => {
  37. it('eats through the cake toward the aim, grows, then digests back', () => {
  38. const game = newGame();
  39. const { id } = game.addPlayer('C', 'castor');
  40. for (let i = 0; i < 120; i++) game.step();
  41. const [start] = game.snapshot();
  42. expect(start.size).toBe(20);
  43. // Burrow straight down for 2 s.
  44. for (let i = 0; i < 120; i++) {
  45. const [p] = game.snapshot();
  46. game.setInput(id, { buttons: Button.Special, aimX: Math.round(p.x), aimY: Math.round(p.y + 200) });
  47. game.step();
  48. }
  49. const [fed] = game.snapshot();
  50. expect(game.events.length).toBeGreaterThan(10);
  51. expect(fed.y).toBeGreaterThan(start.y + 40);
  52. expect(fed.size).toBeGreaterThan(start.size + 3);
  53. expect(fed.anim & Anim.Special).toBeTruthy();
  54. game.setInput(id, { buttons: 0, aimX: 0, aimY: 0 });
  55. for (let i = 0; i < 60 * 30; i++) game.step();
  56. expect(game.snapshot()[0].size).toBe(20);
  57. });
  58. it('a bite knocks back and stuns a player in reach, not one out of reach', () => {
  59. const game = newGame();
  60. const castor = game.addPlayer('C', 'castor');
  61. const victim = game.addPlayer('V', 'generic');
  62. for (let i = 0; i < 120; i++) game.step();
  63. const c = game.snapshot().find((p) => p.id === castor.id)!;
  64. game.teleport(victim.id, c.x + 200, c.y);
  65. game.setInput(castor.id, { buttons: Button.Attack, aimX: Math.round(c.x + 200), aimY: Math.round(c.y) });
  66. game.step();
  67. expect(game.snapshot().find((p) => p.id === victim.id)!.anim & Anim.Stunned).toBe(0);
  68. game.setInput(castor.id, { buttons: 0, aimX: Math.round(c.x + 200), aimY: Math.round(c.y) });
  69. for (let i = 0; i < 30; i++) game.step(); // attack cooldown
  70. game.teleport(victim.id, c.x + 50, c.y);
  71. game.setInput(castor.id, { buttons: Button.Attack, aimX: Math.round(c.x + 200), aimY: Math.round(c.y) });
  72. game.step();
  73. const v = game.snapshot().find((p) => p.id === victim.id)!;
  74. expect(v.anim & Anim.Stunned).toBeTruthy();
  75. expect(v.vx).toBeGreaterThan(200);
  76. expect(v.vy).toBeLessThan(0);
  77. });
  78. });
  79. describe('falling chunks (M2)', () => {
  80. /** Replays the server's event stream on a fresh copy of the map, like a client does. */
  81. function replay(game: Game): number {
  82. const materials = realMaterials();
  83. const { terrain } = loadMap(ASSETS, 'gateau-etages', materials);
  84. for (const { ev } of game.events) applyTerrainEvent(terrain, ev, materials);
  85. return terrain.hash();
  86. }
  87. it('cutting under the top tier makes it fall, land and merge back; clients stay bit-identical', () => {
  88. const game = newGame();
  89. // Slice through the base of the top tier (x 780..1268, base at y ≈ 446..470).
  90. for (let x = 760; x <= 1290; x += 12) game.queueOp({ kind: 'carve', x, y: 462, r: 14, power: 99 });
  91. const first = game.step();
  92. const detach = first.terrain.find((e) => e.ev.kind === 'detach' && !e.ev.debris);
  93. expect(detach).toBeDefined();
  94. expect(game.chunkStates()).toHaveLength(1);
  95. const startY = game.chunkStates()[0].y;
  96. let merged = false;
  97. for (let i = 0; i < 60 * 10 && !merged; i++) merged = game.step().terrain.some((e) => e.ev.kind === 'merge');
  98. expect(merged).toBe(true);
  99. expect(game.chunkStates()).toHaveLength(0);
  100. // It fell onto the middle tier, so the cake top is now lower.
  101. const lastMerge = [...game.events].reverse().find((e) => e.ev.kind === 'merge')!;
  102. expect(lastMerge.ev.kind === 'merge' && lastMerge.ev.y).toBeGreaterThan(startY);
  103. // A client replaying the stream gets exactly the server's bitmap.
  104. expect(replay(game)).toBe(game.terrain.hash());
  105. });
  106. it('a chunk falling on a player crushes them', () => {
  107. const game = newGame();
  108. const { id } = game.addPlayer('Victime', 'generic');
  109. game.teleport(id, 640, 400);
  110. for (let i = 0; i < 90; i++) game.step();
  111. const p = game.snapshot()[0];
  112. expect(p.grounded).toBe(true);
  113. // A chocolate ball appears in the air above them: unanchored, it falls at once.
  114. game.queueOp({ kind: 'fill', x: Math.round(p.x), y: Math.round(p.y - 220), r: 40, material: 2 });
  115. const first = game.step();
  116. expect(first.terrain.some((e) => e.ev.kind === 'detach' && !e.ev.debris)).toBe(true);
  117. let crushed = false;
  118. for (let i = 0; i < 60 * 2 && !crushed; i++) crushed = game.step().crushed.some((c) => c.id === id);
  119. expect(crushed).toBe(true);
  120. });
  121. });
  122. describe('falling chunks: rotated landing', () => {
  123. it('a tilted chunk still merges (diagonal-only crumbs are dropped) and clients stay identical', () => {
  124. const materials = realMaterials();
  125. const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
  126. const game = new Game({ map: definition, terrain, materials, seed: 3, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
  127. // Cut a slanted corner off the middle tier's right side so it tumbles onto the bottom tier.
  128. for (let k = 0; k < 14; k++) game.queueOp({ kind: 'carve', x: 1390 + k * 10, y: 470 + k * 9, r: 12, power: 99 });
  129. for (let y = 470; y <= 600; y += 10) game.queueOp({ kind: 'carve', x: 1395, y, r: 12, power: 99 });
  130. game.step();
  131. expect(game.chunkStates().length).toBeGreaterThan(0);
  132. let merges = 0;
  133. for (let i = 0; i < 60 * 12 && game.chunkStates().length > 0; i++) merges += game.step().terrain.filter((e) => e.ev.kind === 'merge').length;
  134. expect(game.chunkStates()).toHaveLength(0);
  135. expect(merges).toBeGreaterThan(0);
  136. const replayed = loadMap(ASSETS, 'gateau-etages', materials).terrain;
  137. for (const { ev } of game.events) applyTerrainEvent(replayed, ev, materials);
  138. expect(replayed.hash()).toBe(game.terrain.hash());
  139. });
  140. });
  141. describe('Castor Glouton: no flying', () => {
  142. it('holding eat while aiming at empty sky does not lift him', () => {
  143. const game = newGame();
  144. const { id } = game.addPlayer('C', 'castor');
  145. for (let i = 0; i < 120; i++) game.step();
  146. const [start] = game.snapshot();
  147. for (let i = 0; i < 90; i++) {
  148. const [p] = game.snapshot();
  149. game.setInput(id, { buttons: Button.Special, aimX: Math.round(p.x), aimY: Math.round(p.y - 300) });
  150. game.step();
  151. }
  152. expect(game.snapshot()[0].y).toBeGreaterThan(start.y - 10);
  153. });
  154. });