ops.test.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. import { describe, expect, it } from 'vitest';
  2. import { Rng } from '../src/shared/rng';
  3. import { TerrainBitmap } from '../src/shared/terrain/bitmap';
  4. import { applyOp, isValidOp, type TerrainOp } from '../src/shared/terrain/ops';
  5. import { bitmapFrom, realMaterials } from './helpers';
  6. const materials = realMaterials();
  7. function filled(w: number, h: number, mat: number): TerrainBitmap {
  8. return new TerrainBitmap(w, h, new Uint8Array(w * h).fill(mat));
  9. }
  10. function randomOps(seed: number, count: number, w: number, h: number): TerrainOp[] {
  11. const rng = new Rng(seed);
  12. const ops: TerrainOp[] = [];
  13. for (let i = 0; i < count; i++) {
  14. const x = rng.int(0, w - 1);
  15. const y = rng.int(0, h - 1);
  16. const r = rng.int(1, 30);
  17. const k = rng.int(0, 2);
  18. if (k === 0) ops.push({ kind: 'carve', x, y, r, power: rng.int(0, 80) });
  19. else if (k === 1) ops.push({ kind: 'fill', x, y, r, material: rng.int(1, 5) });
  20. else ops.push({ kind: 'convert', x, y, r, from: 1, to: 8 });
  21. }
  22. return ops;
  23. }
  24. describe('terrain ops', () => {
  25. it('carve removes destructible pixels within the radius only', () => {
  26. const bm = filled(21, 21, 1);
  27. const res = applyOp(bm, { kind: 'carve', x: 10, y: 10, r: 3, power: 50 }, materials);
  28. expect(bm.get(10, 10)).toBe(0);
  29. expect(bm.get(13, 10)).toBe(0);
  30. expect(bm.get(14, 10)).toBe(1);
  31. expect(bm.get(12, 12)).toBe(0); // 2² + 2² = 8 <= 9
  32. expect(bm.get(13, 11)).toBe(1); // 3² + 1² = 10 > 9
  33. expect(res.removedByMaterial.get(1)).toBe(29); // integer disc of radius 3 has 29 pixels
  34. });
  35. it('power is compared to hardness: chocolate resists a weak carve', () => {
  36. const bm = filled(9, 9, 2);
  37. applyOp(bm, { kind: 'carve', x: 4, y: 4, r: 3, power: 40 }, materials);
  38. expect(bm.get(4, 4)).toBe(2);
  39. applyOp(bm, { kind: 'carve', x: 4, y: 4, r: 3, power: 60 }, materials);
  40. expect(bm.get(4, 4)).toBe(0);
  41. });
  42. it('the plate is indestructible', () => {
  43. const bm = filled(9, 9, 255);
  44. const res = applyOp(bm, { kind: 'carve', x: 4, y: 4, r: 4, power: 0xffff }, materials);
  45. expect(res.changed).toBeNull();
  46. expect(bm.get(4, 4)).toBe(255);
  47. });
  48. it('fill only writes empty pixels; convert only touches the source material', () => {
  49. const bm = bitmapFrom(['.....', '.111.', '.121.', '.111.', '.....']);
  50. applyOp(bm, { kind: 'fill', x: 2, y: 2, r: 2, material: 7 }, materials);
  51. expect(bm.get(2, 0)).toBe(7);
  52. expect(bm.get(2, 2)).toBe(2);
  53. expect(bm.get(1, 1)).toBe(1);
  54. applyOp(bm, { kind: 'convert', x: 2, y: 2, r: 2, from: 1, to: 8 }, materials);
  55. expect(bm.get(1, 1)).toBe(8);
  56. expect(bm.get(2, 2)).toBe(2);
  57. expect(bm.get(2, 0)).toBe(7);
  58. });
  59. it('ops are clamped at the map border', () => {
  60. const bm = filled(10, 10, 1);
  61. expect(() => applyOp(bm, { kind: 'carve', x: -5, y: 12, r: 8, power: 99 }, materials)).not.toThrow();
  62. expect(bm.get(0, 9)).toBe(0);
  63. });
  64. it('rejects invalid ops (floats, plate fill, unknown kind)', () => {
  65. expect(isValidOp({ kind: 'carve', x: 1.5, y: 1, r: 3, power: 1 })).toBe(false);
  66. expect(isValidOp({ kind: 'fill', x: 1, y: 1, r: 3, material: 255 })).toBe(false);
  67. expect(isValidOp({ kind: 'nuke', x: 1, y: 1, r: 3 })).toBe(false);
  68. expect(isValidOp({ kind: 'carve', x: 1, y: 1, r: 3, power: 1 })).toBe(true);
  69. });
  70. it('is deterministic: same op sequence -> bit-identical bitmaps', () => {
  71. const ops = randomOps(42, 500, 256, 128);
  72. const a = filled(256, 128, 1);
  73. const b = filled(256, 128, 1);
  74. for (const op of ops) applyOp(a, op, materials);
  75. for (const op of ops) applyOp(b, op, materials);
  76. expect(a.hash()).toBe(b.hash());
  77. expect(Buffer.from(a.data).equals(Buffer.from(b.data))).toBe(true);
  78. // Golden value: changing it means the op semantics changed, which breaks network compatibility.
  79. expect(a.hash()).toMatchInlineSnapshot(`2655657281`);
  80. });
  81. it('marks the chunks around the change dirty', () => {
  82. const bm = filled(256, 256, 1);
  83. bm.takeDirtyChunks();
  84. applyOp(bm, { kind: 'carve', x: 64, y: 10, r: 2, power: 99 }, materials);
  85. // Change straddles the chunk border at x = 64 -> chunks 0 and 1 of the first row.
  86. expect(bm.takeDirtyChunks()).toEqual([0, 1]);
  87. expect(bm.takeDirtyChunks()).toEqual([]);
  88. });
  89. });