maps.test.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. import { describe, expect, it } from 'vitest';
  2. import { listMapIds, loadCharacterStats, loadMap } from '../src/server/assets';
  3. import { Game } from '../src/server/game';
  4. import { isModifierId } from '../src/server/modifiers';
  5. import { defaultMap, lastPlaced, rotationPool, sanitizeRotation } from '../src/server/rotation';
  6. import { TICK_RATE } from '../src/shared/constants';
  7. import { Rng } from '../src/shared/rng';
  8. import type { MapSummary } from '../src/shared/protocol';
  9. import { ASSETS, realMaterials } from './helpers';
  10. const materials = realMaterials();
  11. const catalog: MapSummary[] = [
  12. { id: 'a', name: 'A' },
  13. { id: 'b', name: 'B' },
  14. { id: 'c', name: 'C' },
  15. ];
  16. describe('built-in maps', () => {
  17. it('all load, validate, and only use known modifiers', () => {
  18. const ids = listMapIds(ASSETS);
  19. expect(ids).toEqual(expect.arrayContaining(['gateau-etages', 'mille-feuille', 'piece-montee']));
  20. for (const id of ids) {
  21. const { definition, terrain } = loadMap(ASSETS, id, materials);
  22. expect(terrain.width).toBe(definition.size[0]);
  23. for (const m of definition.modifiers) expect(isModifierId(m), `${id}: ${m}`).toBe(true);
  24. }
  25. });
  26. it('la pièce montée: removing a chou drops the column above it', () => {
  27. const { definition, terrain } = loadMap(ASSETS, 'piece-montee', materials);
  28. const game = new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
  29. // Bottom row, centre chou: centre (1024, 905 - 42).
  30. game.queueOp({ kind: 'carve', x: 1024, y: 863, r: 44, power: 99 });
  31. const res = game.step();
  32. const heights = res.terrain.flatMap((e) => (e.ev.kind === 'detach' && !e.ev.debris ? [e.ev.h] : []));
  33. expect(heights.length).toBeGreaterThanOrEqual(1);
  34. expect(Math.max(...heights)).toBeGreaterThan(300); // the whole column, not just one chou
  35. });
  36. });
  37. describe('rotation', () => {
  38. it('pool: the selection when valid, every map otherwise', () => {
  39. expect(rotationPool({ mode: 'all', selection: ['b'] }, catalog)).toEqual(catalog);
  40. expect(rotationPool({ mode: 'selection', selection: ['c', 'a'] }, catalog).map((m) => m.id)).toEqual(['a', 'c']);
  41. expect(rotationPool({ mode: 'selection', selection: ['zzz'] }, catalog)).toEqual(catalog);
  42. expect(sanitizeRotation({ mode: 'selection', selection: ['a', 'zzz', 'a'] }, catalog)).toEqual({ mode: 'selection', selection: ['a'] });
  43. });
  44. it('default map: in order, or seeded random', () => {
  45. const rng = new Rng(1);
  46. expect([1, 2, 3, 4].map((r) => defaultMap({ mode: 'all', selection: [] }, catalog, r, rng))).toEqual(['a', 'b', 'c', 'a']);
  47. const pick = (): string[] => [1, 2, 3, 4, 5].map((r) => defaultMap({ mode: 'random', selection: [] }, catalog, r, new Rng(9 + r)));
  48. expect(pick()).toEqual(pick());
  49. });
  50. it('the last-placed player picks', () => {
  51. expect(
  52. lastPlaced([
  53. { id: 1, name: '', characterId: '', roundScore: 5, total: 20 },
  54. { id: 2, name: '', characterId: '', roundScore: 1, total: 3 },
  55. { id: 3, name: '', characterId: '', roundScore: 9, total: 3 },
  56. ]),
  57. ).toBe(2);
  58. expect(lastPlaced([])).toBeNull();
  59. });
  60. });
  61. describe('modifiers', () => {
  62. function round(mapId: string, seed = 3): Game {
  63. const { definition, terrain } = loadMap(ASSETS, mapId, materials);
  64. return new Game({ map: definition, terrain, materials, seed, rules: { duration: 180 }, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
  65. }
  66. it('wind blows players sideways (piece-montee)', () => {
  67. const game = round('piece-montee');
  68. const { id } = game.addPlayer('Plume', 'generic');
  69. game.teleport(id, 300, 300);
  70. let maxWind = 0;
  71. const x0 = game.snapshot()[0].x;
  72. let drift = 0;
  73. for (let i = 0; i < 12 * TICK_RATE; i++) {
  74. game.step();
  75. maxWind = Math.max(maxWind, Math.abs(game.roundState().wind));
  76. if (i === 30) drift = game.snapshot().find((p) => p.id === id)!.x - x0;
  77. }
  78. expect(maxWind).toBeGreaterThan(50);
  79. expect(Math.abs(drift)).toBeGreaterThan(0);
  80. });
  81. it('random cracks open in the brittle pastry over time (mille-feuille), deterministically', () => {
  82. const a = round('mille-feuille', 4);
  83. const b = round('mille-feuille', 4);
  84. for (let i = 0; i < 10 * TICK_RATE; i++) {
  85. a.step();
  86. b.step();
  87. }
  88. expect(a.events.length).toBeGreaterThan(10);
  89. expect(a.terrain.hash()).toBe(b.terrain.hash());
  90. });
  91. it('modifiers are off during the warm-up', () => {
  92. const { definition, terrain } = loadMap(ASSETS, 'mille-feuille', materials);
  93. const warm = new Game({ map: definition, terrain, materials, seed: 4, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
  94. for (let i = 0; i < 10 * TICK_RATE; i++) warm.step();
  95. expect(warm.events).toHaveLength(0);
  96. });
  97. });