Back to Sickman Survival
SOURCE / PINNED RELEASE

Made of little things.

Sickman Survival

Release
7335c9fdf0a8…
Author-recorded commit
cbd2eeafa70d…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-8804151df7f

Archive hash verified: 591bab4addcbe5fc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

tests/game.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
import {
  Game,
  NO_CONTROLS,
  PLAYER_H,
  KILL_REACH,
  KNOCK_REACH,
  spawnInterval,
  blastRadius,
} from '../src/game.ts';
import { AIR, BEDROCK, CELL, COLLIDE_NONE, COLLIDE_SOLID, PLANK, STONE, CAP, generateWorld } from '../src/terrain.ts';
import { createMole, MOLE_BLAST, MOLE_FUSE } from '../src/moles.ts';
import { createRng } from '../src/rng.ts';
import { BONES } from '../src/stickman.ts';

const STEP = 1 / 120;
const FLOOR_ROW = 120;

/** Replace the generated world with open air above a flat stone floor, and no moles. */
function arena(seed = 7) {
  const game = new Game(seed);
  game.moles.length = 0;
  const t = game.terrain;
  t.fg.fill(AIR);
  t.bg.fill(0);
  for (let y = FLOOR_ROW; y < t.rows; y++) for (let x = 0; x < t.cols; x++) t.set(x, y, STONE);
  game.players[0].body.x = 1200;
  game.players[0].body.y = FLOOR_ROW * CELL;
  return game;
}

function run(game, seconds, controls = NO_CONTROLS) {
  for (let i = 0; i < Math.round(seconds / STEP); i++) {
    if (typeof controls === 'function') {
      game.update(STEP, (slot) => (i === 0 ? controls(slot) : { ...controls(slot), jumpPressed: false }));
    } else {
      game.update(STEP, i === 0 ? controls : { ...controls, jumpPressed: false });
    }
  }
}

test('world generation is deterministic and bounded by bedrock', () => {
  const a = generateWorld(42);
  const b = generateWorld(42);
  assert.deepEqual(a.terrain.fg, b.terrain.fg);
  assert.notDeepEqual(a.terrain.fg, generateWorld(43).terrain.fg);
  const t = a.terrain;
  for (let x = 0; x < t.cols; x++) assert.equal(t.fgAt(x, t.rows - 1), BEDROCK);
  const kinds = new Set(a.landmarks.map((l) => l.kind));
  for (const kind of ['building', 'tree', 'mushroom']) assert.ok(kinds.has(kind), `world has a ${kind}`);
});

test('the stickman spawns on open ground and stays standing', () => {
  for (const seed of [1, 2, 3, 99, 12345, 2024, 777, 31337, 4242, 8]) {
    const game = new Game(seed);
    assert.equal(game.bodyBlocked(game.players[0].body.x, game.players[0].body.y), false, `seed ${seed} spawn is clear`);
    const col = Math.floor(game.players[0].body.x / CELL);
    const row = game.players[0].body.y / CELL;
    for (let y = row; y < row + 6; y++) {
      assert.notEqual(game.terrain.fgAt(col, y), AIR, `seed ${seed} spawns on the ground, not a floating island`);
    }
    const startY = game.players[0].body.y;
    run(game, 2);
    assert.ok(game.players[0].body.grounded, `seed ${seed} grounded`);
    assert.ok(Math.abs(game.players[0].body.y - startY) < CELL * 3, `seed ${seed} did not fall away`);
  }
});

test('houses are big enough to walk in through the door and jump up a storey', () => {
  let upstairs = 0;
  for (const seed of [1, 2, 3, 42, 777]) {
    const buildings = generateWorld(seed).landmarks.filter((l) => l.kind === 'building');
    assert.ok(buildings.length >= 3, `seed ${seed} has several houses`);
    for (const { x0, x1 } of buildings) {
      assert.ok(x1 - x0 + 1 >= 28, 'houses are wide');
      const game = new Game(seed);
      game.moles.length = 0;
      const t = game.terrain;
      const base = t.surface[x0];
      game.players[0].body.x = (x0 - 0.5) * CELL;
      game.players[0].body.y = base * CELL;
      game.players[0].body.vx = game.players[0].body.vy = 0;
      assert.equal(game.bodyBlocked(game.players[0].body.x, game.players[0].body.y), false, `seed ${seed} doorstep is clear`);
      run(game, 0.45, { ...NO_CONTROLS, right: true });
      assert.ok(game.players[0].body.x > (x0 + 3) * CELL, `seed ${seed} house at ${x0}: walked in the door`);
      assert.equal(game.players[0].body.y, base * CELL, 'standing on the ground floor');

      // Jump up through the plank ceiling where nothing solid sits above it.
      const upper = base - 14;
      let col = -1;
      for (let c = x0 + 4; c < x1 - 4 && col < 0; c++) {
        let ok = true;
        for (let dx = -1; dx <= 1; dx++) {
          if (t.fgAt(c + dx, upper) !== PLANK) ok = false;
          // Nothing to land on short of the upper floor, and nothing solid to bump a head on.
          for (let y = upper - 7; y < base; y++) if (y !== upper && t.collisionAt(c + dx, y) !== COLLIDE_NONE) ok = false;
          for (let y = upper - 15; y < upper - 7; y++) if (t.collisionAt(c + dx, y) === COLLIDE_SOLID) ok = false;
        }
        if (ok) col = c;
      }
      if (col < 0) continue;
      game.players[0].body.x = (col + 0.5) * CELL;
      game.players[0].body.vx = 0;
      run(game, 0.2);
      game.update(STEP, { ...NO_CONTROLS, jump: true, jumpPressed: true });
      run(game, 1.2, { ...NO_CONTROLS, jump: true });
      assert.equal(game.players[0].body.y, upper * CELL, `seed ${seed} house at ${x0}: landed on the upper floor`);
      upstairs++;
    }
  }
  assert.ok(upstairs >= 3, 'several multi-storey houses were climbed');
});

test('blasts carve terrain but never bedrock', () => {
  const world = generateWorld(5);
  const t = world.terrain;
  const x = 300 * CELL;
  const y = (t.rows - 2) * CELL;
  const result = t.carve(x, y, 60);
  assert.ok(result.removed > 50);
  assert.equal(t.fgAt(300, t.rows - 1), BEDROCK);
  assert.equal(t.fgAt(300, t.rows - 10), AIR);
});

test('running, jumping and landing on flat ground', () => {
  const game = arena();
  run(game, 0.2);
  assert.ok(game.players[0].body.grounded);
  const x0 = game.players[0].body.x;
  run(game, 1, { ...NO_CONTROLS, right: true });
  assert.ok(game.players[0].body.x - x0 > 120, 'ran right');
  assert.equal(game.players[0].body.facing, 1);

  let peak = game.players[0].body.y;
  game.update(STEP, { ...NO_CONTROLS, jump: true, jumpPressed: true });
  for (let i = 0; i < 120; i++) {
    game.update(STEP, { ...NO_CONTROLS, jump: true });
    peak = Math.min(peak, game.players[0].body.y);
  }
  assert.ok(FLOOR_ROW * CELL - peak > 60, 'jump clears a storey');
  assert.ok(game.players[0].body.grounded, 'landed again');
  assert.equal(game.players[0].body.y, FLOOR_ROW * CELL);
});

test('walks up small steps but is blocked by walls', () => {
  const game = arena();
  const col = Math.floor(game.players[0].body.x / CELL) + 4;
  for (let x = col; x < col + 20; x++) game.terrain.set(x, FLOOR_ROW - 1, STONE);
  run(game, 0.2);
  run(game, 0.6, { ...NO_CONTROLS, right: true });
  assert.equal(game.players[0].body.y, (FLOOR_ROW - 1) * CELL, 'stepped up one cell');

  const wall = Math.floor(game.players[0].body.x / CELL) + 4;
  for (let y = FLOOR_ROW - 20; y < FLOOR_ROW; y++) game.terrain.set(wall, y, STONE);
  run(game, 1, { ...NO_CONTROLS, right: true });
  assert.ok(game.players[0].body.x < wall * CELL, 'stopped by the wall');
});

test('one-way planks: jump up through, land on top, drop down through', () => {
  const game = arena();
  const plankRow = FLOOR_ROW - 12;
  const col = Math.floor(game.players[0].body.x / CELL);
  for (let x = col - 6; x <= col + 6; x++) game.terrain.set(x, plankRow, PLANK);
  run(game, 0.2);
  run(game, 1.2, { ...NO_CONTROLS, jump: true, jumpPressed: true });
  assert.equal(game.players[0].body.y, plankRow * CELL, 'standing on the plank');
  assert.ok(game.players[0].body.grounded);

  run(game, 0.6, { ...NO_CONTROLS, down: true });
  assert.equal(game.players[0].body.y, FLOOR_ROW * CELL, 'dropped back to the floor');
});

test('crouching crawls slowly through a gap too low to walk through', () => {
  const game = arena();
  const col = Math.floor(game.players[0].body.x / CELL);
  // A 4-cell-high tunnel: a low ceiling from 6 to 30 cells right of the player.
  for (let x = col + 6; x <= col + 30; x++) for (let y = FLOOR_ROW - 20; y < FLOOR_ROW - 4; y++) game.terrain.set(x, y, STONE);
  run(game, 0.2);

  run(game, 1, { ...NO_CONTROLS, right: true });
  assert.ok(game.players[0].body.x < (col + 6) * CELL, 'standing, the tunnel blocks');

  const before = game.players[0].body.x;
  run(game, 0.5, { ...NO_CONTROLS, right: true, crouch: true });
  assert.ok(game.players[0].body.ducked);
  assert.equal(game.players[0].body.state, 'crawl');
  const crawled = game.players[0].body.x - before;
  assert.ok(crawled > 10 && crawled < 0.5 * 165 * 0.5, `crawls in, slower than running (${crawled})`);

  // Letting go inside the tunnel keeps the stickman down until there is headroom.
  run(game, 0.3, NO_CONTROLS);
  assert.ok(game.players[0].body.ducked, 'no room to stand');
  run(game, 4, { ...NO_CONTROLS, right: true });
  assert.ok(game.players[0].body.x > (col + 31) * CELL, 'crawled out the far side');
  run(game, 0.2, { ...NO_CONTROLS, right: true });
  assert.equal(game.players[0].body.ducked, false, 'stands up again in the open');
});

test('mushroom caps bounce the stickman', () => {
  const game = arena();
  const col = Math.floor(game.players[0].body.x / CELL);
  for (let x = col - 3; x <= col + 3; x++) game.terrain.set(x, FLOOR_ROW - 3, CAP);
  game.players[0].body.y = (FLOOR_ROW - 30) * CELL;
  game.players[0].body.vy = 300;
  let rose = false;
  for (let i = 0; i < 120; i++) {
    game.update(STEP, NO_CONTROLS);
    if (game.players[0].body.vy < -400) rose = true;
  }
  assert.ok(rose, 'bounced upward');
});

test('a direct hit kills and ragdolls; a near miss throws the stickman back', () => {
  const near = arena();
  run(near, 0.2);
  const radius = 24;
  const px = near.players[0].body.x;
  const blastX = px - 10 - radius * ((KILL_REACH + KNOCK_REACH) / 2);
  near.explode(blastX, near.players[0].body.y - PLAYER_H / 2, radius);
  assert.ok(near.alive, 'survived the near miss');
  assert.ok(near.players[0].body.vx > 150, 'pushed away from the blast');
  assert.ok(near.players[0].body.vy < 0, 'lifted off the ground');

  const direct = arena();
  run(direct, 0.2);
  let died = 0;
  direct.listeners.death = () => died++;
  direct.explode(direct.players[0].body.x + 3, direct.players[0].body.y - 10, radius);
  assert.equal(direct.alive, false);
  assert.equal(died, 1);
  assert.ok(direct.players[0].ragdoll);
  // A blast centred on the body must still throw it, not cancel out.
  const x0 = direct.players[0].ragdoll.centerX();
  const y0 = direct.players[0].ragdoll.centerY();
  let travelled = 0;
  for (let i = 0; i < 60; i++) {
    direct.update(STEP, NO_CONTROLS);
    travelled = Math.max(travelled, Math.hypot(direct.players[0].ragdoll.centerX() - x0, direct.players[0].ragdoll.centerY() - y0));
  }
  assert.ok(travelled > 80, `body was flung (${travelled.toFixed(0)} units)`);
});

test('ragdoll stays in one piece and comes to rest on the ground', () => {
  const game = arena();
  run(game, 0.2);
  game.explode(game.players[0].body.x - 4, game.players[0].body.y - 4, 30);
  run(game, 6);
  const r = game.players[0].ragdoll;
  for (const [a, b] of BONES) assert.ok(r.distance(a, b) < 12, `bone ${a}-${b} intact`);
  for (let j = 0; j < r.pos.length; j += 2) {
    assert.ok(r.pos[j + 1] <= game.terrain.height, 'joint above the world floor');
    assert.notEqual(game.terrain.collisionAtPoint(r.pos[j], r.pos[j + 1]), 1, 'joint not inside solid');
  }
});

test('the storm escalates', () => {
  assert.ok(spawnInterval(0) > spawnInterval(60));
  assert.ok(spawnInterval(60) > spawnInterval(120));
  assert.equal(spawnInterval(10_000), 0.16);
  assert.ok(blastRadius(120, 0.5) > blastRadius(0, 0.5));

  const game = new Game(11);
  game.meteorsEnabled = true;
  let blasts = 0;
  game.listeners.explosion = () => blasts++;
  run(game, 12);
  assert.ok(blasts >= 3, `meteors landed (${blasts})`);
});

/** Arena where everything counts as underground, with one mole placed in it. */
function moleArena(x, y, state) {
  const game = arena();
  game.terrain.surface.fill(0);
  const mole = createMole(x, y, createRng(3));
  mole.state = state;
  game.moles.push(mole);
  return { game, mole };
}

test('worlds have caves under the landscape, reachable from the surface', () => {
  for (const seed of [1, 2, 3, 42, 777]) {
    const { terrain: t, landmarks } = generateWorld(seed);
    assert.ok(landmarks.some((l) => l.kind === 'cave-mouth'), `seed ${seed} has a cave mouth`);
    // Flood the open air from the sky and see how deep it reaches.
    const seen = new Uint8Array(t.cols * t.rows);
    const queue = [];
    for (let x = 0; x < t.cols; x++) {
      seen[x] = 1;
      queue.push(x);
    }
    let deepest = 0;
    let reachedCave = 0;
    while (queue.length) {
      const i = queue.pop();
      const x = i % t.cols;
      const y = Math.floor(i / t.cols);
      const depth = y - t.surface[x];
      deepest = Math.max(deepest, depth);
      if (depth > 20) reachedCave++;
      for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
        if (!t.inBounds(nx, ny)) continue;
        const j = ny * t.cols + nx;
        if (seen[j] || t.collisionAt(nx, ny) === COLLIDE_SOLID) continue;
        seen[j] = 1;
        queue.push(j);
      }
    }
    assert.ok(deepest > 60, `seed ${seed} caves reach ${deepest} cells deep`);
    assert.ok(reachedCave > 2000, `seed ${seed} has a sizeable connected cave network (${reachedCave} cells)`);
    for (let x = 0; x < t.cols; x++) assert.equal(t.fgAt(x, t.rows - 1), BEDROCK);
  }
});

test('moles start buried away from the stickman and tunnel without changing the world', () => {
  const game = new Game(21);
  assert.ok(game.moles.length >= 10);
  for (const m of game.moles) {
    assert.equal(m.state, 'burrow');
    assert.equal(game.terrain.collisionAtPoint(m.x, m.y), COLLIDE_SOLID, 'spawned inside the earth');
    assert.ok(game.terrain.depthAt(m.x, m.y) >= 10, 'spawned underground');
    assert.ok(Math.hypot(m.x - game.players[0].body.x, m.y - game.players[0].body.y) >= 300, 'spawned away from the stickman');
  }
  const before = Uint8Array.from(game.terrain.fg);
  const states = new Set();
  for (let i = 0; i < 30 / STEP; i++) {
    game.update(STEP, NO_CONTROLS);
    for (const m of game.moles) {
      states.add(m.state);
      assert.ok(game.terrain.depthAt(m.x, m.y) > 2, 'moles stay underground');
    }
  }
  assert.ok(states.has('walk'), 'some moles surfaced into the caves');
  assert.deepEqual(game.terrain.fg, before, 'burrowing left the terrain untouched');
  assert.ok(game.alive);
});

test('a burrowing mole pops out into a cave pocket', () => {
  const x = 600;
  const y = (FLOOR_ROW + 20) * CELL;
  const { game, mole } = moleArena(x, y, 'burrow');
  const t = game.terrain;
  for (let cy = FLOOR_ROW + 10; cy < FLOOR_ROW + 30; cy++) {
    for (let cx = Math.floor(x / CELL) - 14; cx < Math.floor(x / CELL) + 14; cx++) {
      if (Math.hypot(cx * CELL - x, cy * CELL - y) > 18) t.set(cx, cy, AIR);
    }
  }
  mole.minBurrow = 0;
  run(game, 4);
  assert.equal(mole.state, 'walk');
  assert.equal(t.collisionAtPoint(mole.x, mole.y), COLLIDE_NONE);
});

test('getting close to a surfaced mole arms it, and it blows the stickman up', () => {
  const { game, mole } = moleArena(1260, FLOOR_ROW * CELL - 4, 'walk');
  run(game, 0.2);
  assert.equal(mole.state, 'walk', 'not armed while the stickman is far');
  let blasts = 0;
  game.listeners.explosion = (b) => {
    blasts++;
    assert.equal(b.radius, MOLE_BLAST);
  };
  // Walk up to it, then stand still: it shuffles in and goes off.
  for (let i = 0; i < 1.5 / STEP && game.moles.length; i++) {
    game.update(STEP, { ...NO_CONTROLS, right: mole.state === 'walk' });
  }
  assert.equal(game.moles.length, 0, 'the mole detonated');
  assert.equal(blasts, 1);
  assert.equal(game.alive, false);
  assert.equal(game.deathCause, 'mole');
});

test('a mole fuse leaves time to run, and the blast still throws the stickman', () => {
  const { game, mole } = moleArena(1225, FLOOR_ROW * CELL - 4, 'walk');
  run(game, 0.05);
  assert.equal(mole.state, 'armed');
  run(game, 0.35, { ...NO_CONTROLS, left: true });
  let thrown = 0;
  for (let i = 0; i < MOLE_FUSE / STEP; i++) {
    game.update(STEP, NO_CONTROLS);
    thrown = Math.min(thrown, game.players[0].body.vy);
  }
  assert.equal(game.moles.length, 0, 'the mole detonated');
  assert.ok(game.alive, 'got clear of the kill zone');
  assert.ok(thrown < -100, 'but was thrown by the blast');
});

test('explosions set off nearby moles, even buried ones', () => {
  const game = arena();
  const rng = createRng(5);
  const buried = createMole(600, (FLOOR_ROW + 8) * CELL, rng);
  const far = createMole(2000, (FLOOR_ROW + 8) * CELL, rng);
  game.moles.push(buried, far);
  let blasts = 0;
  game.listeners.explosion = () => blasts++;
  game.explode(600, FLOOR_ROW * CELL, 28);
  assert.equal(buried.state, 'armed');
  assert.notEqual(far.state, 'armed');
  run(game, 0.5);
  assert.equal(blasts, 2, 'chain reaction');
  assert.ok(game.moles.includes(far));
  assert.equal(game.alive, true);
});

// Local multiplayer ───────────────────────────────────────────────────────

/** Arena with several players standing apart on the floor. */
function squad(slots, spacing = 300) {
  const game = arena();
  for (const slot of slots) if (slot !== 0) game.addPlayer(slot);
  game.players.forEach((p, i) => {
    p.body.x = 1200 + i * spacing;
    p.body.y = FLOOR_ROW * CELL;
    p.shield = 0;
  });
  run(game, 0.05);
  return game;
}

test('a blast kills only the player inside it, and the run goes on', () => {
  const game = squad([0, 1]);
  run(game, 0.2);
  const deaths = [];
  let over = 0;
  game.listeners.death = (slot) => deaths.push(slot);
  game.listeners.runOver = () => over++;
  const p1 = game.player(1).body;
  game.explode(p1.x + 3, p1.y - 10, 24);
  assert.deepEqual(deaths, [1]);
  assert.equal(game.player(0).alive, true);
  assert.equal(game.alive, true, 'the run continues while anyone stands');
  assert.equal(over, 0);

  const p0 = game.player(0).body;
  game.explode(p0.x + 3, p0.y - 10, 24);
  assert.equal(game.alive, false);
  assert.equal(over, 1, 'the run ends with the last player');
});

test('the survival clock runs until the last player is down', () => {
  const game = squad([0, 1]);
  game.meteorsEnabled = false;
  run(game, 0.2);
  game.meteorsEnabled = true;
  const p0 = game.player(0).body;
  game.explode(p0.x + 3, p0.y - 10, 24);
  const before = game.survived;
  run(game, 1);
  assert.ok(game.survived > before + 0.9, 'clock keeps running for the survivor');
});

test('players are controlled independently', () => {
  const game = squad([0, 1]);
  run(game, 0.2);
  const x0 = game.player(0).body.x;
  const x1 = game.player(1).body.x;
  run(game, 0.5, (slot) => (slot === 1 ? { ...NO_CONTROLS, right: true } : NO_CONTROLS));
  assert.equal(game.player(0).body.x, x0, 'P1 stood still');
  assert.ok(game.player(1).body.x - x1 > 50, 'P2 ran right');
});

test('a joining player drops in beside a living teammate, shielded', () => {
  const game = arena();
  game.meteorsEnabled = true;
  run(game, 0.2);
  const joined = game.addPlayer(2);
  const anchor = game.player(0).body;
  assert.ok(Math.abs(joined.body.x - anchor.x) <= 14 && joined.body.x !== anchor.x, "drops in just beside the teammate");
  assert.equal(joined.body.y, anchor.y);
  assert.equal(game.bodyBlocked(joined.body.x, joined.body.y), false);
  assert.ok(joined.shield > 0);
  game.explode(joined.body.x + 3, joined.body.y - 10, 24);
  assert.equal(joined.alive, true, 'spawn shield blocks a lethal blast');
  assert.equal(game.player(0).alive, false, 'the unshielded teammate still dies');
  assert.equal(game.addPlayer(2), joined, 'adding a slot twice is a no-op');
});

test('unplugging a player keeps the run going unless they were the last one standing', () => {
  const game = squad([0, 1, 2]);
  let over = 0;
  game.listeners.runOver = () => over++;
  game.removePlayer(1);
  assert.deepEqual(game.players.map((p) => p.slot), [0, 2]);
  assert.equal(over, 0);
  const p0 = game.player(0).body;
  game.explode(p0.x + 3, p0.y - 10, 24);
  assert.equal(over, 0);
  game.removePlayer(2);
  assert.equal(over, 1, 'removing the last survivor ends the run');
});

test('a dead player spectates their body, then the nearest living teammate', () => {
  const game = squad([0, 1, 2], 200);
  game.player(2).body.x = 3000;
  run(game, 0.05);
  const p0 = game.player(0).body;
  game.explode(p0.x + 3, p0.y - 10, 24);
  assert.equal(game.viewTarget(0).slot, 0, 'follows own body at first');
  run(game, 2.5);
  assert.equal(game.viewTarget(0).slot, 1, 'then the nearest survivor');
});

test('moles hunt the nearest living player', () => {
  const game = squad([0, 1], 400);
  const target = game.player(1).body;
  const mole = createMole(target.x - 60, FLOOR_ROW * CELL - 6, createRng(3));
  mole.state = 'walk';
  mole.grounded = true;
  mole.facing = -1;
  game.moles.push(mole);
  run(game, 1.5);
  assert.ok(mole.state === 'armed' || mole.x > target.x - 50, 'went after P2, the closer player');
});