Преглед изворни кода

feat(terrain): falling chunks (M2)

- DetachFinder: after each op, finds components no longer 4-connected to a
  plate pixel. Each seed searches breadth-first on a small budget (meets
  pixels already proved anchored), then depth-first downward.
- One sequenced terrain event stream (op, detach, merge) applied by the
  server and every client; masks and patches are RLE-encoded.
- FallingChunk: dynamic planck body approximated with grid-aligned boxes
  (chains never collide with chains). Tiny components crumble as debris.
- Resting chunks are rasterized back (inverse transform, no holes), glued
  to the ground below; merges that would leave a piece hanging are
  retried, diagonal-only crumbs are dropped from the patch.
- Chunks hitting a player hard enough crush them (respawn for now).
- Tests: detach rules, RLE, rasterization, boxes, tier fall and merge,
  tilted landing, crush, and server/client bitmap identity by replay.

Known: a whole tier coming loose (~90k px) takes ~9 ms on the server,
above the 2 ms budget of readme §5 (noted in the roadmap).

No new dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman пре 1 недеља
родитељ
комит
ae09b96d64

+ 138 - 0
src/server/physics/fallingChunk.ts

@@ -0,0 +1,138 @@
+import { Box, type Body, type World } from 'planck';
+import { CHUNK_BOX_CELL_PX, CHUNK_MAX_BOXES_PER_AXIS, MATERIAL_EMPTY, PIXELS_PER_METER } from '../../shared/constants';
+import type { MaterialRegistry } from '../../shared/materials';
+import type { ChunkInfo, ChunkState } from '../../shared/protocol';
+import { rleEncode } from '../../shared/terrain/rle';
+
+const PPM = PIXELS_PER_METER;
+
+export interface FixtureTag {
+  chunk?: number;
+  player?: number;
+}
+
+interface BoxRect {
+  x0: number;
+  y0: number;
+  x1: number;
+  y1: number;
+}
+
+/**
+ * Covers a mask with axis-aligned boxes on a coarse grid: a cell counts as solid when at
+ * least a third of its pixels are. Runs of solid cells are merged per row, then rows with
+ * identical runs are merged vertically. Box2D needs convex shapes, and chains never collide
+ * with chains, so this is how a falling chunk gets a body that collides with the terrain.
+ */
+export function boxesForMask(mask: Uint8Array, w: number, h: number): BoxRect[] {
+  const cell = Math.max(CHUNK_BOX_CELL_PX, Math.ceil(Math.max(w, h) / CHUNK_MAX_BOXES_PER_AXIS));
+  const gw = Math.ceil(w / cell);
+  const gh = Math.ceil(h / cell);
+  const solid = new Uint8Array(gw * gh);
+  for (let gy = 0; gy < gh; gy++) {
+    for (let gx = 0; gx < gw; gx++) {
+      let n = 0;
+      let total = 0;
+      for (let y = gy * cell; y < Math.min(h, (gy + 1) * cell); y++) {
+        for (let x = gx * cell; x < Math.min(w, (gx + 1) * cell); x++) {
+          total++;
+          if (mask[y * w + x] !== MATERIAL_EMPTY) n++;
+        }
+      }
+      solid[gy * gw + gx] = n * 3 >= total ? 1 : 0;
+    }
+  }
+
+  const boxes: BoxRect[] = [];
+  let open: BoxRect[] = []; // boxes that may still grow downward
+  for (let gy = 0; gy < gh; gy++) {
+    const runs: [number, number][] = [];
+    for (let gx = 0; gx < gw; gx++) {
+      if (!solid[gy * gw + gx]) continue;
+      const start = gx;
+      while (gx + 1 < gw && solid[gy * gw + gx + 1]) gx++;
+      runs.push([start, gx + 1]);
+    }
+    const next: BoxRect[] = [];
+    for (const [a, b] of runs) {
+      const x0 = a * cell;
+      const x1 = Math.min(w, b * cell);
+      const above = open.find((r) => r.x0 === x0 && r.x1 === x1 && r.y1 === gy * cell);
+      if (above) {
+        above.y1 = Math.min(h, (gy + 1) * cell);
+        next.push(above);
+      } else {
+        const r = { x0, y0: gy * cell, x1, y1: Math.min(h, (gy + 1) * cell) };
+        boxes.push(r);
+        next.push(r);
+      }
+    }
+    open = next;
+  }
+  // Thin slivers smaller than a cell still need a body.
+  if (boxes.length === 0) boxes.push({ x0: 0, y0: 0, x1: w, y1: h });
+  return boxes;
+}
+
+/** A detached piece of terrain simulated as a dynamic body until it rests. */
+export class FallingChunk {
+  readonly body: Body;
+  readonly maskRle: number[];
+  /** Seconds spent below the rest speed. */
+  restTime = 0;
+  /** Delay before trying to merge again after a failed merge. */
+  retryIn = 0;
+
+  constructor(
+    world: World,
+    readonly id: number,
+    readonly mask: Uint8Array,
+    readonly w: number,
+    readonly h: number,
+    x: number,
+    y: number,
+    materials: MaterialRegistry,
+  ) {
+    this.maskRle = rleEncode(mask);
+    let density = 0;
+    let friction = 0;
+    let restitution = 0;
+    let n = 0;
+    for (const m of mask) {
+      const mat = m === MATERIAL_EMPTY ? undefined : materials.byId[m];
+      if (!mat) continue;
+      density += mat.density;
+      friction += mat.friction;
+      restitution += mat.restitution;
+      n++;
+    }
+    n = Math.max(1, n);
+    this.body = world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, userData: { chunk: id } });
+    const tag: FixtureTag = { chunk: id };
+    for (const b of boxesForMask(mask, w, h)) {
+      const hw = (b.x1 - b.x0) / 2;
+      const hh = (b.y1 - b.y0) / 2;
+      this.body.createFixture(new Box(hw / PPM, hh / PPM, { x: (b.x0 + hw) / PPM, y: (b.y0 + hh) / PPM }), {
+        density: density / n,
+        friction: friction / n,
+        restitution: Math.min(0.3, restitution / n),
+        userData: tag,
+      });
+    }
+  }
+
+  state(): ChunkState {
+    const p = this.body.getPosition();
+    return { id: this.id, x: p.x * PPM, y: p.y * PPM, angle: this.body.getAngle() };
+  }
+
+  info(): ChunkInfo {
+    return { id: this.id, w: this.w, h: this.h, mask: this.maskRle, state: this.state() };
+  }
+
+  /** Linear speed in px/s. */
+  speed(): number {
+    const v = this.body.getLinearVelocity();
+    return Math.hypot(v.x, v.y) * PPM;
+  }
+}

+ 180 - 0
src/shared/terrain/detach.ts

@@ -0,0 +1,180 @@
+import { MATERIAL_EMPTY, MATERIAL_PLATE, MAX_COMPONENT_PIXELS } from '../constants';
+import type { Rect, TerrainBitmap } from './bitmap';
+
+/** A connected group of solid pixels that no longer touches a plate pixel. */
+export interface DetachedComponent {
+  /** Bounds in map pixels (x1/y1 exclusive). */
+  bounds: Rect;
+  /** w × h materials, 0 where the pixel is not part of the component. */
+  mask: Uint8Array;
+  pixelCount: number;
+}
+
+const STAMP_LIMIT = 0xfffffff0;
+/** Pixels explored breadth-first before a search switches to a downward depth-first walk. */
+const LOCAL_SEARCH_BUDGET = 2048;
+
+/**
+ * Rule (readme §5): every solid pixel not 4-connected to a plate pixel falls.
+ *
+ * The search is seeded from every solid pixel in `region` (the area an op just changed,
+ * grown by one pixel). Each seed first explores breadth-first on a small budget, which
+ * usually meets a pixel an earlier seed already proved anchored; then depth-first, downward
+ * first, since plates sit under the terrain. Only detached components get explored completely.
+ *
+ * Within one call, every pixel reached by a search that found an anchor is anchored too,
+ * so later seeds stop as soon as they touch one.
+ */
+export class DetachFinder {
+  /** Search id that last pushed each pixel. */
+  private readonly seen: Uint32Array;
+  /** Per call: 2·call = anchored, 2·call + 1 = detached. */
+  private readonly known: Uint32Array;
+  private readonly stack: Int32Array;
+  private readonly pushed: Int32Array;
+  private searchId = 0;
+  private callId = 0;
+
+  constructor(private readonly bitmap: TerrainBitmap) {
+    const n = bitmap.width * bitmap.height;
+    this.seen = new Uint32Array(n);
+    this.known = new Uint32Array(n);
+    this.stack = new Int32Array(n);
+    this.pushed = new Int32Array(n);
+  }
+
+  /** Finds detached components touching `region`. Does not modify the bitmap. */
+  find(region: Rect): DetachedComponent[] {
+    const { width, height, data } = this.bitmap;
+    const call = this.nextCall();
+    const x0 = Math.max(0, region.x0 - 1);
+    const y0 = Math.max(0, region.y0 - 1);
+    const x1 = Math.min(width, region.x1 + 1);
+    const y1 = Math.min(height, region.y1 + 1);
+    const out: DetachedComponent[] = [];
+    for (let y = y0; y < y1; y++) {
+      for (let x = x0; x < x1; x++) {
+        const i = y * width + x;
+        if (data[i] === MATERIAL_EMPTY) continue;
+        const k = this.known[i];
+        if (k === 2 * call || k === 2 * call + 1) continue;
+        const component = this.explore(i, call);
+        if (component) out.push(component);
+      }
+    }
+    return out;
+  }
+
+  /** True if the solid pixel at `index` is connected to a plate. */
+  isAnchored(index: number): boolean {
+    if (this.bitmap.data[index] === MATERIAL_EMPTY) return false;
+    return this.explore(index, this.nextCall()) === null;
+  }
+
+  private nextCall(): number {
+    if (this.callId >= STAMP_LIMIT / 2 || this.searchId >= STAMP_LIMIT) {
+      this.seen.fill(0);
+      this.known.fill(0);
+      this.callId = 0;
+      this.searchId = 0;
+    }
+    return ++this.callId;
+  }
+
+  /** DFS from `start`. Returns null if anchored, the component otherwise. Hot path: kept flat on purpose. */
+  private explore(start: number, call: number): DetachedComponent | null {
+    const { width, height, data } = this.bitmap;
+    const { seen, known, stack, pushed } = this;
+    const anchoredMark = 2 * call;
+    const search = ++this.searchId;
+    const last = width * height - width; // first index of the bottom row
+    let count = 0;
+    let anchored = false;
+    seen[start] = search;
+    pushed[count++] = start;
+
+    // Phase 1: breadth-first on `pushed` (used as a queue) within a small budget. It spreads
+    // sideways and quickly meets pixels an earlier search already proved anchored.
+    // Phase 2, if still unresolved: depth-first on `stack`, heading for the plates below.
+    let head = 0;
+    let top = 0;
+    let deep = false;
+    search: for (;;) {
+      let i: number;
+      if (!deep) {
+        if (head >= count) break;
+        if (count >= LOCAL_SEARCH_BUDGET) {
+          deep = true;
+          for (let k = head; k < count; k++) stack[top++] = pushed[k];
+          continue;
+        }
+        i = pushed[head++];
+      } else {
+        if (top === 0) break;
+        i = stack[--top];
+      }
+      if (data[i] === MATERIAL_PLATE || count > MAX_COMPONENT_PIXELS) {
+        // Plate reached, or too big to fall: anchored.
+        anchored = true;
+        break;
+      }
+      const x = i % width;
+      // In the deep phase the last pushed is explored first: up, left, right, then down.
+      for (let d = 0; d < 4; d++) {
+        let n: number;
+        if (d === 0) {
+          if (i < width) continue;
+          n = i - width;
+        } else if (d === 1) {
+          if (x === 0) continue;
+          n = i - 1;
+        } else if (d === 2) {
+          if (x === width - 1) continue;
+          n = i + 1;
+        } else {
+          if (i >= last) continue;
+          n = i + width;
+        }
+        if (data[n] === MATERIAL_EMPTY || seen[n] === search) continue;
+        if (known[n] === anchoredMark) {
+          anchored = true;
+          break search;
+        }
+        seen[n] = search;
+        pushed[count++] = n;
+        if (deep) stack[top++] = n;
+      }
+    }
+
+    if (anchored) {
+      for (let k = 0; k < count; k++) known[pushed[k]] = anchoredMark;
+      return null;
+    }
+
+    // Exhausted without meeting an anchor: detached.
+    let minX = width;
+    let minY = height;
+    let maxX = -1;
+    let maxY = -1;
+    for (let k = 0; k < count; k++) {
+      const i = pushed[k];
+      known[i] = anchoredMark + 1;
+      const x = i % width;
+      const y = (i - x) / width;
+      if (x < minX) minX = x;
+      if (x > maxX) maxX = x;
+      if (y < minY) minY = y;
+      if (y > maxY) maxY = y;
+    }
+    const w = maxX - minX + 1;
+    const h = maxY - minY + 1;
+    const mask = new Uint8Array(w * h);
+    for (let k = 0; k < count; k++) {
+      const i = pushed[k];
+      const x = i % width;
+      const y = (i - x) / width;
+      mask[(y - minY) * w + (x - minX)] = data[i];
+    }
+    return { bounds: { x0: minX, y0: minY, x1: maxX + 1, y1: maxY + 1 }, mask, pixelCount: count };
+  }
+}

+ 150 - 0
src/shared/terrain/events.ts

@@ -0,0 +1,150 @@
+import { MATERIAL_EMPTY } from '../constants';
+import type { MaterialRegistry } from '../materials';
+import type { Rect, TerrainBitmap } from './bitmap';
+import { applyOp, isValidOp, type TerrainOp } from './ops';
+import { rleDecode } from './rle';
+
+// Every change to the terrain bitmap goes through one sequenced event stream, so the
+// server and every client apply exactly the same mutations in exactly the same order.
+
+export type TerrainEvent =
+  | { kind: 'op'; op: TerrainOp }
+  /** A component lost its anchor: its pixels leave the bitmap (and become a falling chunk unless `debris`). */
+  | { kind: 'detach'; id: number; x: number; y: number; w: number; h: number; mask: number[]; debris: boolean }
+  /** A chunk came to rest: `patch` (RLE, 0 = untouched) is written into empty pixels. */
+  | { kind: 'merge'; id: number; x: number; y: number; w: number; h: number; patch: number[] };
+
+export interface SequencedEvent {
+  seq: number;
+  ev: TerrainEvent;
+}
+
+export interface EventResult {
+  changed: Rect | null;
+  /** Pixels removed by an op, by previous material (for effects). */
+  removedByMaterial: Map<number, number>;
+}
+
+const isInt = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n);
+
+export function isValidEvent(ev: unknown): ev is TerrainEvent {
+  if (!ev || typeof ev !== 'object') return false;
+  const e = ev as Record<string, unknown>;
+  if (e.kind === 'op') return isValidOp(e.op);
+  if (e.kind !== 'detach' && e.kind !== 'merge') return false;
+  if (!isInt(e.id) || !isInt(e.x) || !isInt(e.y) || !isInt(e.w) || !isInt(e.h) || (e.w as number) <= 0 || (e.h as number) <= 0) return false;
+  return Array.isArray(e.kind === 'detach' ? e.mask : e.patch);
+}
+
+/** Applies one event. Integer-only: identical input gives an identical bitmap everywhere. */
+export function applyTerrainEvent(bitmap: TerrainBitmap, ev: TerrainEvent, materials: MaterialRegistry): EventResult {
+  if (ev.kind === 'op') return applyOp(bitmap, ev.op, materials);
+
+  const { x, y, w, h } = ev;
+  const cells = rleDecode(ev.kind === 'detach' ? ev.mask : ev.patch, w * h);
+  const { width, data } = bitmap;
+  for (let ly = 0; ly < h; ly++) {
+    const wy = y + ly;
+    if (wy < 0 || wy >= bitmap.height) continue;
+    for (let lx = 0; lx < w; lx++) {
+      const v = cells[ly * w + lx];
+      const wx = x + lx;
+      if (v === MATERIAL_EMPTY || wx < 0 || wx >= width) continue;
+      const i = wy * width + wx;
+      if (ev.kind === 'detach') data[i] = MATERIAL_EMPTY;
+      else if (data[i] === MATERIAL_EMPTY) data[i] = v;
+    }
+  }
+  const changed = { x0: x, y0: y, x1: x + w, y1: y + h };
+  bitmap.markDirty(changed);
+  return { changed, removedByMaterial: new Map() };
+}
+
+export interface Patch {
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+  /** w × h, 0 = leave the pixel alone. */
+  cells: Uint8Array;
+  pixelCount: number;
+}
+
+/**
+ * Rasterizes a chunk mask (local pixel frame, origin at its top-left corner) placed at
+ * (ox, oy) with rotation `angle`, into the empty pixels of `bitmap`. Each world pixel
+ * center is mapped back into the mask (inverse transform), so rotated chunks have no holes.
+ * Then, so a resting chunk actually connects to what it rests on, each column's lowest
+ * written pixel is extended down by up to `gluePx` pixels when solid ground is that close.
+ * Server-side only: the result is sent as data, clients never recompute it.
+ */
+export function rasterizeChunk(
+  bitmap: TerrainBitmap,
+  mask: Uint8Array,
+  w: number,
+  h: number,
+  ox: number,
+  oy: number,
+  angle: number,
+  gluePx: number,
+): Patch | null {
+  const cos = Math.cos(angle);
+  const sin = Math.sin(angle);
+  const corners = [
+    [0, 0],
+    [w, 0],
+    [0, h],
+    [w, h],
+  ].map(([lx, ly]) => [ox + lx * cos - ly * sin, oy + lx * sin + ly * cos]);
+  const x0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
+  const y0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
+  const x1 = Math.min(bitmap.width, Math.ceil(Math.max(...corners.map((c) => c[0]))) + 1);
+  const y1 = Math.min(bitmap.height, Math.ceil(Math.max(...corners.map((c) => c[1]))) + 1 + gluePx);
+  if (x1 <= x0 || y1 <= y0) return null;
+
+  const pw = x1 - x0;
+  const ph = y1 - y0;
+  const cells = new Uint8Array(pw * ph);
+  let count = 0;
+  for (let wy = y0; wy < y1; wy++) {
+    for (let wx = x0; wx < x1; wx++) {
+      if (bitmap.data[wy * bitmap.width + wx] !== MATERIAL_EMPTY) continue;
+      const dx = wx + 0.5 - ox;
+      const dy = wy + 0.5 - oy;
+      const lx = Math.floor(dx * cos + dy * sin);
+      const ly = Math.floor(-dx * sin + dy * cos);
+      if (lx < 0 || ly < 0 || lx >= w || ly >= h) continue;
+      const m = mask[ly * w + lx];
+      if (m === MATERIAL_EMPTY) continue;
+      cells[(wy - y0) * pw + (wx - x0)] = m;
+      count++;
+    }
+  }
+  if (count === 0) return null;
+
+  // Glue: close small gaps between the chunk's underside and the ground.
+  for (let px = 0; px < pw; px++) {
+    let lowest = -1;
+    for (let py = ph - 1; py >= 0; py--) {
+      if (cells[py * pw + px] !== MATERIAL_EMPTY) {
+        lowest = py;
+        break;
+      }
+    }
+    if (lowest < 0) continue;
+    const m = cells[lowest * pw + px];
+    const wx = x0 + px;
+    for (let k = 1; k <= gluePx; k++) {
+      const wy = y0 + lowest + k;
+      if (wy >= bitmap.height) break;
+      if (bitmap.data[wy * bitmap.width + wx] !== MATERIAL_EMPTY) {
+        for (let f = 1; f < k; f++) {
+          cells[(lowest + f) * pw + px] = m;
+          count++;
+        }
+        break;
+      }
+    }
+  }
+  return { x: x0, y: y0, w: pw, h: ph, cells, pixelCount: count };
+}

+ 31 - 0
src/shared/terrain/rle.ts

@@ -0,0 +1,31 @@
+/** Run-length encoding of a byte mask as alternating [value, count] pairs. Compact for blocky masks. */
+export function rleEncode(data: Uint8Array): number[] {
+  const out: number[] = [];
+  let i = 0;
+  while (i < data.length) {
+    const v = data[i];
+    let n = 1;
+    while (i + n < data.length && data[i + n] === v) n++;
+    out.push(v, n);
+    i += n;
+  }
+  return out;
+}
+
+/** Decodes an RLE array into exactly `length` bytes. Throws on malformed input. */
+export function rleDecode(rle: readonly number[], length: number): Uint8Array {
+  if (rle.length % 2 !== 0) throw new Error('RLE: odd length');
+  const out = new Uint8Array(length);
+  let o = 0;
+  for (let i = 0; i < rle.length; i += 2) {
+    const v = rle[i];
+    const n = rle[i + 1];
+    if (!Number.isInteger(v) || v < 0 || v > 255 || !Number.isInteger(n) || n <= 0 || o + n > length) {
+      throw new Error('RLE: malformed run');
+    }
+    out.fill(v, o, o + n);
+    o += n;
+  }
+  if (o !== length) throw new Error(`RLE: decoded ${o} bytes, expected ${length}`);
+  return out;
+}

+ 113 - 0
tests/detach.test.ts

@@ -0,0 +1,113 @@
+import { describe, expect, it } from 'vitest';
+import { TerrainBitmap } from '../src/shared/terrain/bitmap';
+import { DetachFinder } from '../src/shared/terrain/detach';
+import { applyTerrainEvent, rasterizeChunk } from '../src/shared/terrain/events';
+import { rleDecode, rleEncode } from '../src/shared/terrain/rle';
+import { boxesForMask } from '../src/server/physics/fallingChunk';
+import { bitmapFrom, realMaterials } from './helpers';
+
+const materials = realMaterials();
+const all = (bm: TerrainBitmap) => ({ x0: 0, y0: 0, x1: bm.width, y1: bm.height });
+
+describe('detached components', () => {
+  it('everything connected to a plate stays', () => {
+    const bm = bitmapFrom(['......', '.11...', '.1....', '.1111.', '######']);
+    expect(new DetachFinder(bm).find(all(bm))).toEqual([]);
+  });
+
+  it('a floating island detaches with its exact mask', () => {
+    const bm = bitmapFrom(['.......', '..23...', '..1....', '.......', '#######']);
+    const [c] = new DetachFinder(bm).find(all(bm));
+    expect(c.bounds).toEqual({ x0: 2, y0: 1, x1: 4, y1: 3 });
+    expect([...c.mask]).toEqual([2, 3, 1, 0]);
+    expect(c.pixelCount).toBe(3);
+  });
+
+  it('diagonal contact does not anchor (4-connectivity)', () => {
+    const bm = bitmapFrom(['.1...', '..1..', '..1..', '#####']);
+    const comps = new DetachFinder(bm).find(all(bm));
+    expect(comps).toHaveLength(1);
+    expect(comps[0].pixelCount).toBe(1);
+  });
+
+  it('finds every separate island in the region, once each', () => {
+    const bm = bitmapFrom(['1.1.1', '.....', '1.1.1', '.....', '#####']);
+    expect(new DetachFinder(bm).find(all(bm))).toHaveLength(6);
+  });
+
+  it('only looks around the changed region', () => {
+    const bm = bitmapFrom(['1......', '.......', '.....1.', '.....1.', '#######']);
+    const comps = new DetachFinder(bm).find({ x0: 0, y0: 0, x1: 1, y1: 1 });
+    expect(comps).toHaveLength(1);
+    expect(comps[0].bounds.x0).toBe(0);
+  });
+
+  it('cutting the only support of a ledge detaches the ledge', () => {
+    const rows = ['..........', '.1111111..', '.1........', '.1........', '##########'];
+    const bm = bitmapFrom(rows);
+    const finder = new DetachFinder(bm);
+    expect(finder.find(all(bm))).toEqual([]);
+    bm.data[3 * bm.width + 1] = 0; // cut the pillar
+    const [c] = finder.find({ x0: 1, y0: 3, x1: 2, y1: 4 });
+    expect(c.pixelCount).toBe(8);
+    expect(finder.isAnchored(1 * bm.width + 3)).toBe(false);
+  });
+
+  it('a detach event removes exactly the masked pixels', () => {
+    const bm = bitmapFrom(['.....', '.12..', '..1..', '.....', '#####']);
+    const [c] = new DetachFinder(bm).find(all(bm));
+    const { x0, y0, x1, y1 } = c.bounds;
+    applyTerrainEvent(bm, { kind: 'detach', id: 1, x: x0, y: y0, w: x1 - x0, h: y1 - y0, mask: rleEncode(c.mask), debris: false }, materials);
+    expect([...bm.data].filter((v) => v !== 0 && v !== 255)).toEqual([]);
+  });
+});
+
+describe('RLE', () => {
+  it('round-trips and rejects malformed input', () => {
+    const data = new Uint8Array([0, 0, 0, 5, 5, 1, 0, 0]);
+    expect(rleEncode(data)).toEqual([0, 3, 5, 2, 1, 1, 0, 2]);
+    expect([...rleDecode(rleEncode(data), data.length)]).toEqual([...data]);
+    expect(() => rleDecode([1, 3], 2)).toThrow();
+    expect(() => rleDecode([1], 1)).toThrow();
+  });
+});
+
+describe('chunk rasterization', () => {
+  it('writes an unrotated chunk back where it was, into empty pixels only', () => {
+    const bm = new TerrainBitmap(8, 8);
+    bm.data[5 * 8 + 3] = 4; // obstacle inside the chunk area
+    const mask = new Uint8Array([1, 1, 1, 1, 1, 1]); // 3 × 2
+    const patch = rasterizeChunk(bm, mask, 3, 2, 2, 4, 0, 0)!;
+    expect(patch.pixelCount).toBe(5);
+    applyTerrainEvent(bm, { kind: 'merge', id: 1, x: patch.x, y: patch.y, w: patch.w, h: patch.h, patch: rleEncode(patch.cells) }, materials);
+    expect(bm.get(2, 4)).toBe(1);
+    expect(bm.get(3, 5)).toBe(4);
+    expect(bm.get(4, 5)).toBe(1);
+  });
+
+  it('a quarter turn keeps the pixel count and leaves no holes', () => {
+    const bm = new TerrainBitmap(32, 32);
+    const mask = new Uint8Array(10 * 4).fill(1);
+    const patch = rasterizeChunk(bm, mask, 10, 4, 20, 5, Math.PI / 2, 0)!;
+    expect(patch.pixelCount).toBe(40);
+  });
+
+  it('glues a chunk resting a couple of pixels above the ground', () => {
+    const bm = new TerrainBitmap(6, 8);
+    for (let x = 0; x < 6; x++) bm.data[7 * 6 + x] = 255;
+    const patch = rasterizeChunk(bm, new Uint8Array([1, 1]), 2, 1, 2, 4, 0, 3)!;
+    applyTerrainEvent(bm, { kind: 'merge', id: 1, x: patch.x, y: patch.y, w: patch.w, h: patch.h, patch: rleEncode(patch.cells) }, materials);
+    expect(bm.get(2, 5)).toBe(1);
+    expect(bm.get(2, 6)).toBe(1);
+    expect(new DetachFinder(bm).isAnchored(4 * 6 + 2)).toBe(true);
+  });
+});
+
+describe('chunk collision boxes', () => {
+  it('covers a rectangle with one box and an L with two', () => {
+    expect(boxesForMask(new Uint8Array(24 * 12).fill(1), 24, 12)).toEqual([{ x0: 0, y0: 0, x1: 24, y1: 12 }]);
+    const l = new Uint8Array(24 * 24);
+    for (let y = 0; y < 24; y++) for (let x = 0; x < 24; x++) if (x < 12 || y >= 12) l[y * 24 + x] = 1;
+    expect(boxesForMask(l, 24, 24)).toHaveLength(2);
+  });
+});

+ 170 - 0
tests/game.test.ts

@@ -0,0 +1,170 @@
+import { describe, expect, it } from 'vitest';
+import { loadCharacterStats, loadMap } from '../src/server/assets';
+import { Game } from '../src/server/game';
+import { Anim } from '../src/shared/characters';
+import { Button } from '../src/shared/input';
+import { applyTerrainEvent } from '../src/shared/terrain/events';
+import { ASSETS, realMaterials } from './helpers';
+
+function newGame(): Game {
+  const materials = realMaterials();
+  const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
+  return new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+}
+
+describe('server game', () => {
+  it('a spawned player lands on the cake and stays on it', () => {
+    const game = newGame();
+    const { id } = game.addPlayer('A', 'generic');
+    for (let i = 0; i < 180; i++) game.step();
+    const [p] = game.snapshot();
+    expect(p.id).toBe(id);
+    expect(p.grounded).toBe(true);
+    expect(p.y).toBeLessThan(game.terrain.height);
+  });
+
+  it('attacking carves the terrain and logs sequenced ops', () => {
+    const game = newGame();
+    const { id } = game.addPlayer('A', 'generic');
+    for (let i = 0; i < 120; i++) game.step();
+    const [p] = game.snapshot();
+    const before = game.terrain.hash();
+    game.setInput(id, { buttons: Button.Attack, aimX: Math.round(p.x), aimY: Math.round(p.y + 40) });
+    const applied = game.step().terrain;
+    expect(applied[0]).toMatchObject({ seq: 1, ev: { kind: 'op' } });
+    expect(game.terrain.hash()).not.toBe(before);
+    expect(game.events.length).toBeGreaterThanOrEqual(1);
+  });
+});
+
+describe('Castor Glouton', () => {
+  it('eats through the cake toward the aim, grows, then digests back', () => {
+    const game = newGame();
+    const { id } = game.addPlayer('C', 'castor');
+    for (let i = 0; i < 120; i++) game.step();
+    const [start] = game.snapshot();
+    expect(start.size).toBe(20);
+
+    // Burrow straight down for 2 s.
+    for (let i = 0; i < 120; i++) {
+      const [p] = game.snapshot();
+      game.setInput(id, { buttons: Button.Special, aimX: Math.round(p.x), aimY: Math.round(p.y + 200) });
+      game.step();
+    }
+    const [fed] = game.snapshot();
+    expect(game.events.length).toBeGreaterThan(10);
+    expect(fed.y).toBeGreaterThan(start.y + 40);
+    expect(fed.size).toBeGreaterThan(start.size + 3);
+    expect(fed.anim & Anim.Special).toBeTruthy();
+
+    game.setInput(id, { buttons: 0, aimX: 0, aimY: 0 });
+    for (let i = 0; i < 60 * 30; i++) game.step();
+    expect(game.snapshot()[0].size).toBe(20);
+  });
+
+  it('a bite knocks back and stuns a player in reach, not one out of reach', () => {
+    const game = newGame();
+    const castor = game.addPlayer('C', 'castor');
+    const victim = game.addPlayer('V', 'generic');
+    for (let i = 0; i < 120; i++) game.step();
+    const c = game.snapshot().find((p) => p.id === castor.id)!;
+
+    game.teleport(victim.id, c.x + 200, c.y);
+    game.setInput(castor.id, { buttons: Button.Attack, aimX: Math.round(c.x + 200), aimY: Math.round(c.y) });
+    game.step();
+    expect(game.snapshot().find((p) => p.id === victim.id)!.anim & Anim.Stunned).toBe(0);
+
+    game.setInput(castor.id, { buttons: 0, aimX: Math.round(c.x + 200), aimY: Math.round(c.y) });
+    for (let i = 0; i < 30; i++) game.step(); // attack cooldown
+    game.teleport(victim.id, c.x + 50, c.y);
+    game.setInput(castor.id, { buttons: Button.Attack, aimX: Math.round(c.x + 200), aimY: Math.round(c.y) });
+    game.step();
+    const v = game.snapshot().find((p) => p.id === victim.id)!;
+    expect(v.anim & Anim.Stunned).toBeTruthy();
+    expect(v.vx).toBeGreaterThan(200);
+    expect(v.vy).toBeLessThan(0);
+  });
+});
+
+describe('falling chunks (M2)', () => {
+  /** Replays the server's event stream on a fresh copy of the map, like a client does. */
+  function replay(game: Game): number {
+    const materials = realMaterials();
+    const { terrain } = loadMap(ASSETS, 'gateau-etages', materials);
+    for (const { ev } of game.events) applyTerrainEvent(terrain, ev, materials);
+    return terrain.hash();
+  }
+
+  it('cutting under the top tier makes it fall, land and merge back; clients stay bit-identical', () => {
+    const game = newGame();
+    // Slice through the base of the top tier (x 780..1268, base at y ≈ 446..470).
+    for (let x = 760; x <= 1290; x += 12) game.queueOp({ kind: 'carve', x, y: 462, r: 14, power: 99 });
+    const first = game.step();
+    const detach = first.terrain.find((e) => e.ev.kind === 'detach' && !e.ev.debris);
+    expect(detach).toBeDefined();
+    expect(game.chunkStates()).toHaveLength(1);
+    const startY = game.chunkStates()[0].y;
+
+    let merged = false;
+    for (let i = 0; i < 60 * 10 && !merged; i++) merged = game.step().terrain.some((e) => e.ev.kind === 'merge');
+    expect(merged).toBe(true);
+    expect(game.chunkStates()).toHaveLength(0);
+    // It fell onto the middle tier, so the cake top is now lower.
+    const lastMerge = [...game.events].reverse().find((e) => e.ev.kind === 'merge')!;
+    expect(lastMerge.ev.kind === 'merge' && lastMerge.ev.y).toBeGreaterThan(startY);
+    // A client replaying the stream gets exactly the server's bitmap.
+    expect(replay(game)).toBe(game.terrain.hash());
+  });
+
+  it('a chunk falling on a player crushes them', () => {
+    const game = newGame();
+    const { id } = game.addPlayer('Victime', 'generic');
+    game.teleport(id, 640, 400);
+    for (let i = 0; i < 90; i++) game.step();
+    const p = game.snapshot()[0];
+    expect(p.grounded).toBe(true);
+
+    // A chocolate ball appears in the air above them: unanchored, it falls at once.
+    game.queueOp({ kind: 'fill', x: Math.round(p.x), y: Math.round(p.y - 220), r: 40, material: 2 });
+    const first = game.step();
+    expect(first.terrain.some((e) => e.ev.kind === 'detach' && !e.ev.debris)).toBe(true);
+    let crushed = false;
+    for (let i = 0; i < 60 * 2 && !crushed; i++) crushed = game.step().crushed.some((c) => c.id === id);
+    expect(crushed).toBe(true);
+  });
+});
+
+describe('falling chunks: rotated landing', () => {
+  it('a tilted chunk still merges (diagonal-only crumbs are dropped) and clients stay identical', () => {
+    const materials = realMaterials();
+    const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
+    const game = new Game({ map: definition, terrain, materials, seed: 3, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+    // Cut a slanted corner off the middle tier's right side so it tumbles onto the bottom tier.
+    for (let k = 0; k < 14; k++) game.queueOp({ kind: 'carve', x: 1390 + k * 10, y: 470 + k * 9, r: 12, power: 99 });
+    for (let y = 470; y <= 600; y += 10) game.queueOp({ kind: 'carve', x: 1395, y, r: 12, power: 99 });
+    game.step();
+    expect(game.chunkStates().length).toBeGreaterThan(0);
+    let merges = 0;
+    for (let i = 0; i < 60 * 12 && game.chunkStates().length > 0; i++) merges += game.step().terrain.filter((e) => e.ev.kind === 'merge').length;
+    expect(game.chunkStates()).toHaveLength(0);
+    expect(merges).toBeGreaterThan(0);
+    const replayed = loadMap(ASSETS, 'gateau-etages', materials).terrain;
+    for (const { ev } of game.events) applyTerrainEvent(replayed, ev, materials);
+    expect(replayed.hash()).toBe(game.terrain.hash());
+  });
+});
+
+describe('Castor Glouton: no flying', () => {
+  it('holding eat while aiming at empty sky does not lift him', () => {
+    const game = newGame();
+    const { id } = game.addPlayer('C', 'castor');
+    for (let i = 0; i < 120; i++) game.step();
+    const [start] = game.snapshot();
+    for (let i = 0; i < 90; i++) {
+      const [p] = game.snapshot();
+      game.setInput(id, { buttons: Button.Special, aimX: Math.round(p.x), aimY: Math.round(p.y - 300) });
+      game.step();
+    }
+    expect(game.snapshot()[0].y).toBeGreaterThan(start.y - 10);
+  });
+});