Back to Rail Game
SOURCE / PINNED RELEASE

Made of little things.

Rail Game

Release
ccdd5b913413…
Author-recorded commit
ddf0122b6acc…
License
LICENSE
Author’s source reference
nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-91ec90c6fdf

Archive hash verified: dad7b678e25f96a3…. 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 { readFile } from 'node:fs/promises';
import test from 'node:test';
import ts from 'typescript';
const compile = async path => ts.transpileModule(await readFile(new URL(path, import.meta.url), 'utf8'), {
  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const moduleUrl = code => `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`;
const gameUrl = moduleUrl(await compile('../src/game.ts'));
const { RailGame, STAGES, MAX_SQUAD, STARTING_SQUAD } = await import(gameUrl);
const { parseProgress, mergeProgress, recordRun } = await import(moduleUrl((await compile('../src/progress.ts')).replace("'./game.js'", JSON.stringify(gameUrl))));
const entity = (kind, fields = {}) => ({ id: 1, kind, x: 0, z: 10, hp: 5, maxHp: 5, value: 4, width: 1.65, hit: 0, alive: true, ...fields });
const fresh = () => { const game = new RailGame(); game.entities = []; game.start(); return game; };
const advance = (game, seconds, direction = 0) => { for (let i = 0; i < Math.ceil(seconds * 60); i++) game.update(1 / 60, direction); };

test('movement clamps to the track and pause freezes the entire simulation', () => {
  const game = fresh(); advance(game, 3, -1); assert.equal(game.x, -2.25);
  game.pause(); const before = JSON.stringify(game); advance(game, 3, 1); assert.equal(JSON.stringify(game), before);
  game.start(); advance(game, 3, 1); assert.equal(game.x, 2.25);
});
test('collects a gate once and rejects a gate in another lane', () => {
  const game = fresh(); game.entities = [entity('recruit', { z: .4 }), entity('recruit', { id: 2, x: 2, z: .4 })];
  advance(game, .2); assert.equal(game.squad, STARTING_SQUAD + 4); assert.equal(game.score, 50); advance(game, 1); assert.equal(game.squad, STARTING_SQUAD + 4);
});
test('multipliers grow the formation within the soldier and lane limits', () => {
  const game = fresh(); game.squad = 180; game.entities = [entity('multiply', { z: .4, value: 2 })]; advance(game, .1);
  assert.equal(game.squad, MAX_SQUAD);
  for (let i = 0; i < game.squad; i++) assert.ok(Math.abs(game.formation(i).x) <= .66);
});
test('each soldier contributes an independent automatic projectile', () => {
  const game = fresh(); game.squad = 11; game.update(1 / 60); assert.equal(game.bullets.length, 11);
});
test('damage, rapid fire and shield have distinct effects', () => {
  const game = fresh(); game.entities = [entity('damage', { z: .4 }), entity('rapid', { id: 2, z: .4 }), entity('shield', { id: 3, z: .4 })];
  advance(game, .1); assert.equal(game.damage, 2); assert.ok(game.rapid > 9); assert.equal(game.shield, true);
  assert.ok(game.bullets.every(b => b.damage === 2));
  const rapid = fresh(); rapid.rapid = 10; const normal = fresh(); advance(rapid, .8); advance(normal, .8);
  assert.ok(rapid.bullets.length > normal.bullets.length); advance(game, 11); assert.equal(game.rapid, 0);
});
test('shield absorbs one barrier, then a subsequent collision can defeat the squad', () => {
  const game = fresh(); game.squad = 3; game.shield = true; game.entities = [entity('barrier', { z: .4 })]; advance(game, .1);
  assert.equal(game.squad, 3); assert.equal(game.shield, false); advance(game, 1);
  game.entities.push(entity('barrier', { id: 2, z: .4 })); advance(game, .1); assert.equal(game.phase, 'lost'); assert.equal(game.squad, 0);
});
test('swept bullet hits eliminate enemies and barriers shield targets behind them', () => {
  const game = fresh(); game.entities = [entity('enemy', { z: 1.5, hp: 1 })]; game.update(1 / 60);
  assert.equal(game.entities[0].alive, false); assert.equal(game.kills, 1); assert.equal(game.score, 100);
  const blocked = fresh(); blocked.entities = [entity('barrier', { z: 4 }), entity('enemy', { id: 2, z: 5, hp: 50 })];
  advance(blocked, .2); assert.equal(blocked.entities[1].hp, 50);
});
test('boss cannot be defeated before its arena and victory awards a survivor bonus', () => {
  const game = fresh(); game.entities = [entity('boss', { z: 2, hp: 1 })]; game.update(1 / 60); assert.equal(game.phase, 'playing'); assert.equal(game.boss.hp, 1);
  game.distance = game.definition.length; advance(game, .5); assert.equal(game.phase, 'won'); assert.equal(game.score, 1000 + STARTING_SQUAD * 25);
});
test('hostile shots can be dodged and fatal damage cannot be replaced by a same-frame win', () => {
  const game = fresh(); game.x = 2; game.bullets = [{ x: 0, z: .4, damage: STARTING_SQUAD, hostile: true, vx: 0 }]; game.update(1 / 60); assert.equal(game.squad, STARTING_SQUAD);
  game.x = 0; game.bullets = [{ x: 0, z: .4, damage: STARTING_SQUAD, hostile: true, vx: 0 }]; game.update(1 / 60); assert.equal(game.phase, 'lost');
});
test('retry clears temporary upgrades, projectiles and loss state', () => {
  const game = fresh(); game.damage = 4; game.squad = 20; game.rapid = 5; game.shield = true; advance(game, 1); game.prepare(1);
  assert.equal(game.phase, 'ready'); assert.equal(game.squad, STARTING_SQUAD); assert.equal(game.damage, 1); assert.equal(game.shield, false); assert.equal(game.bullets.length, 0); assert.equal(game.distance, 0);
});
test('progress validates corrupt data, merges late reads and unlocks only completed stages', () => {
  assert.deepEqual(parseProgress('broken'), { unlocked: 0, best: STAGES.map(() => 0) });
  assert.deepEqual(parseProgress('{"unlocked":99,"best":[-1,"999",null]}'), { unlocked: STAGES.length - 1, best: STAGES.map(() => 0) });
  const failed = recordRun(parseProgress(null), 0, 200, false); assert.equal(failed.unlocked, 0);
  const won = recordRun(failed, 0, 900, true); assert.equal(won.unlocked, 1);
  assert.deepEqual(mergeProgress(won, parseProgress('{"unlocked":2,"best":[500,100,0]}')), { unlocked: 2, best: [900, 100, ...STAGES.slice(2).map(() => 0)] });
});

test('larger stages field multi-row enemy formations without putting them outside the track', () => {
  assert.equal(STAGES.length, 8);
  let previousCount = 0;
  for (let stage = 0; stage < STAGES.length; stage++) {
    const game = new RailGame(stage);
    const enemies = game.entities.filter(e => e.kind === 'enemy');
    assert.ok(enemies.length > previousCount);
    assert.ok(enemies.length >= 690);
    const opening = enemies.filter(e => e.z >= 50 && e.z <= 62);
    assert.ok(opening.some(e => e.x < -1) && opening.some(e => e.x > 1));
    const firstRank = enemies.filter(e => e.z === 50);
    assert.equal(firstRank.length, 5);
    assert.ok(firstRank.slice(1).every((e, i) => e.x - firstRank[i].x >= e.width));
    previousCount = enemies.length;
    assert.ok(STAGES[stage].length / STAGES[stage].speed >= 90);
    for (let z = 0; z < STAGES[stage].length - 70; z += 60) {
      assert.ok(game.entities.some(e => e.z >= z && e.z < z + 100), `empty stretch stage ${stage + 1} at ${z}`);
    }
    assert.ok(enemies.every(e => Math.abs(e.x) + e.width / 2 < 3));
  }
});
test('all 250 soldiers fit a bounded formation and sustain independent fire', () => {
  const game = fresh(); game.squad = MAX_SQUAD;
  const positions = Array.from({length:MAX_SQUAD}, (_,i) => game.formation(i));
  assert.equal(new Set(positions.map(p => `${p.x},${p.z}`)).size, MAX_SQUAD);
  assert.ok(positions.every(p => Math.abs(p.x) < .7 && p.z > -4.1 && p.z < 2));
  advance(game, 1); assert.equal(game.bullets.length, 1250);
  const rapid = fresh(); rapid.squad = MAX_SQUAD; rapid.rapid = 10;
  advance(rapid, 1); assert.equal(rapid.bullets.length, 3000);
});
test('later bosses fire wider, dodgeable projectile spreads', () => {
  for (let stage = 0; stage < STAGES.length; stage++) {
    const game = new RailGame(stage); game.entities = [entity('boss', {z:20, hp:100000})];
    game.distance = game.definition.length; game.start(); advance(game, 1.81);
    const shots = game.bullets.filter(b => b.hostile);
    assert.equal(shots.length, 3 + Math.min(3, stage) * 2);
    assert.ok(shots.every((shot, i) => i === 0 || shot.vx > shots[i-1].vx));
  }
});

test('all eight complete stage sequences are winnable with steering and automatic fire', () => {
  for (let stage = 0; stage < STAGES.length; stage++) {
    const game = new RailGame(stage); game.start();
    for (let i = 0; i < 240 * 60 && game.phase === 'playing'; i++) {
      let target = game.x;
      const upcoming = game.entities.filter(e => e.alive && e.kind !== 'boss' && e.z > 0 && e.z < 36).sort((a, b) => a.z - b.z);
      if (game.progress >= 1 && game.boss) target = game.boss.x;
      else if (upcoming.length) {
        const row = upcoming.filter(e => Math.abs(e.z - upcoming[0].z) < 1);
        const pickup = row.find(e => e.kind === 'multiply') ?? row.find(e => e.kind === 'damage') ?? row.find(e => ['recruit', 'rapid', 'shield'].includes(e.kind));
        if (pickup) target = pickup.x;
        else if (row.some(e => e.kind === 'barrier' && Math.abs(e.x - target) < 1.1)) target = [-2, 0, 2].find(x => row.every(e => Math.abs(e.x - x) > 1)) ?? 0;
        else if (row.some(e => e.kind === 'enemy')) target = row.find(e => e.kind === 'enemy').x;
      }
      game.update(1 / 60, 0, target); game.events = [];
    }
    assert.equal(game.phase, 'won', `stage ${stage + 1}: squad=${game.squad}, distance=${game.distance}, boss=${game.boss?.hp}`);
  }
});

// Existing three-stage saves must stay readable when the campaign gains stages.
test('legacy progress preserves scores and exposes the remaining campaign sequentially', () => {
  const legacy = parseProgress('{"unlocked":2,"best":[1200,2300,3400]}');
  assert.equal(legacy.best.length, 8);
  assert.deepEqual(legacy.best.slice(0,3), [1200,2300,3400]);
  assert.ok(legacy.best.slice(3).every(score => score === 0));
  let progress = legacy;
  for (let stage = 2; stage < STAGES.length; stage++) {
    progress = recordRun(progress, stage, 5000, true);
    assert.equal(progress.unlocked, Math.min(stage+1, STAGES.length-1));
  }
});