Pada engine simulasi berbasis web (seperti city builder, game strategi berbasis tick, atau simulasi ekonomi), perubahan logika sekecil apa pun pada satu fungsi dapat memicu dampak eksponensial setelah ribuan tick. Fenomena ini disebut state drift. Pengujian unit standar umumnya hanya menguji input-output fungsi tunggal dan gagal menangkap akumulasi deviasi pada timeline panjang.

Golden Master Testing (atau characterization testing) memecahkan masalah ini dengan merekam output state lengkap dari sebuah siklus eksekusi yang diketahui valid ke dalam fixture referensi. Setiap iterasi kode atau refaktor selanjutnya akan dibandingkan langsung dengan master ini secara deterministik.

Penyebab Utama State Drift pada Web Simulation

Simulasi web yang berjalan di JavaScript/TypeScript menghadapi dua kendala utama terkait determinisme:

  • PRNG Non-deterministik: Penggunaan Math.random() bawaan runtime tidak mendukung seeding. Tiap eksekusi menghasilkan rangkaian nilai acak yang berbeda, membuat perbandingan snapshot mustahil dilakukan.
  • Akumulasi Floating-Point: Standar IEEE 754 dapat memunculkan rounding error kecil yang membesar ketika nilai dikalikan atau dibagi terus-menerus selama ratusan tick.

Solusi Determinisme: Seeded PRNG (Mulberry32)

Gunakan generator angka acak pseudo 32-bit yang deterministik. Mulberry32 memiliki footprint memori sangat kecil, performa tinggi, dan menghasilkan sekuens identik untuk seed yang sama di platform mana pun.

Implementasi Engine Simulasi Deterministik

Berikut implementasi engine simulasi sederhana dalam TypeScript tanpa dependensi eksternal:

// engine.ts
export type SimState = {
  tick: number;
  resources: { gold: number; wood: number };
  entities: Array<{ id: number; x: number; y: number; hp: number }>;
};

// PRNG Mulberry32: menghasilkan float deterministik [0, 1)
export function createPRNG(seed: number) {
  let s = seed | 0;
  return function (): number {
    s = (s + 0x6d2b79f5) | 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export function initSimulation(): SimState {
  return {
    tick: 0,
    resources: { gold: 100, wood: 50 },
    entities: [
      { id: 1, x: 0, y: 0, hp: 100 },
      { id: 2, x: 10, y: 10, hp: 80 },
    ],
  };
}

export function stepSimulation(state: SimState, rand: () => number): SimState {
  // ponytail: fixed-point math diabaikan, rounding manual untuk potong micro-drift
  const nextGold = state.resources.gold + Math.floor(rand() * 5);
  const nextWood = state.resources.wood + (state.tick % 2 === 0 ? 1 : 0);

  const nextEntities = state.entities.map((e) => {
    const moveX = rand() > 0.5 ? 1 : -1;
    const moveY = rand() > 0.5 ? 1 : -1;
    return {
      ...e,
      x: e.x + moveX,
      y: e.y + moveY,
      hp: Math.max(0, e.hp - Math.floor(rand() * 2)),
    };
  });

  return {
    tick: state.tick + 1,
    resources: { gold: nextGold, wood: nextWood },
    entities: nextEntities,
  };
}

Skipped: Floating-point quantization/fixed-point types. Add when: simulasi memakai perhitungan fisika kontinu atau delta time variabel.

Perekaman dan Verifikasi Snapshot

Gunakan modul native node:assert/strict dan node:fs. Pendekatan ini menghilangkan overhead library testing berat seperti Jest atau Vitest untuk simulasi berbasis CLI.

// test-golden-master.ts
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createPRNG, initSimulation, stepSimulation, SimState } from './engine.js';

const MASTER_FILE = path.resolve('fixtures/sim-golden-master.json');
const RUN_TICKS = 200;
const CONSTANT_SEED = 424242;

function executeRun(ticks: number, seed: number): SimState[] {
  const rand = createPRNG(seed);
  let state = initSimulation();
  const timeline: SimState[] = [state];

  for (let i = 0; i < ticks; i++) {
    state = stepSimulation(state, rand);
    timeline.push(state);
  }
  return timeline;
}

const isUpdateMode = process.argv.includes('--update-snapshot');
const currentState = executeRun(RUN_TICKS, CONSTANT_SEED);

if (isUpdateMode || !fs.existsSync(MASTER_FILE)) {
  fs.mkdirSync(path.dirname(MASTER_FILE), { recursive: true });
  fs.writeFileSync(MASTER_FILE, JSON.stringify(currentState, null, 2), 'utf-8');
  console.log(`[INFO] Master fixture diperbarui: ${MASTER_FILE}`);
  process.exit(0);
}

const masterState: SimState[] = JSON.parse(fs.readFileSync(MASTER_FILE, 'utf-8'));

try {
  assert.deepStrictEqual(currentState, masterState);
  console.log(`[PASS] Determinisme identik sepanjang ${RUN_TICKS} ticks.`);
} catch (err) {
  console.error('[FAIL] State drift terdeteksi!');
  
  // Isolasi tick pertama yang mengalami divergensi
  for (let t = 0; t < Math.min(currentState.length, masterState.length); t++) {
    const currStr = JSON.stringify(currentState[t]);
    const masterStr = JSON.stringify(masterState[t]);
    if (currStr !== masterStr) {
      console.error(`Divergensi pertama terjadi pada Tick: ${t}`);
      console.error('Expected (Master):', masterState[t]);
      console.error('Actual   (Current):', currentState[t]);
      break;
    }
  }
  process.exit(1);
}

Isolasi Delta Saat Rule Berubah

Saat aturan permainan sengaja diubah (misalnya rebalancing unit), Golden Master dipastikan gagal. Lakukan langkah berikut:

  1. Periksa Divergensi: Pastikan divergensi pertama muncul tepat pada tick di mana aturan baru mulai aktif.
  2. Cek Pola Drift: Jika perubahan hanya pada gold, namun properti entities.x ikut bergeser, ada kemungkinan pemanggilan PRNG keluar urutan (misalnya fungsi dipanggil kondisional sehingga menggeser state seed).
  3. Update Master: Jalankan runner dengan argumen --update-snapshot untuk memperbarui baseline master setelah delta divalidasi manual.

Integrasi pada CI Pipeline

Jalankan script verifikasi langsung dalam pipeline CI menggunakan native test runner Node.js tanpa dependensi pihak ketiga:

# .github/workflows/sim-test.yml
name: Simulation Regression Checks
on: [push, pull_request]

jobs:
  verify-simulation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: node test-golden-master.js
Catatan: Selalu kunci versi minor runtime Node.js di CI. Implementasi internal Math di V8 engine pada arsitektur tertentu terkadang memiliki deviasi bit presisi terendah pada floating-point. Menjaga arsitektur dan versi Node.js tetap homogen mencegah kegagalan semu.