Aplikasi kompetisi atau pencatatan waktu speedcubing menghadapi dua tantangan utama pada layer backend: manipulasi payload langkah penyelesaian (move validation) dan risiko double-submission akibat latensi atau retry jaringan yang agresif. Validasi state Rubik di API membutuhkan verifikasi integritas notasi langkah dan penanganan konkurensi data yang ketat.

1. Kontrak API: Delta Move vs Full Cube State

Mengirimkan full cube state (array 54 stiker atau koordinat facelet) pada setiap interaksi menghabiskan bandwidth dan memperbesar peluang tampering dari sisi client. Pendekatan standar industri adalah mengirimkan delta moves (urutan rotasi) yang diverifikasi ulang oleh server.

Format pertukaran data yang optimal:

  • Scramble: Urutan langkah acak resmi (dihasilkan oleh server via CSPRNG atau TNoodle-compatible generator).
  • Moves (Delta): Rangkaian notasi standar World Cube Association (WCA) yang dieksekusi user beserta timestamp diferensial (millisecond offset).
  • Final State: Dihitung ulang sepenuhnya oleh server (virtual cube simulator) untuk membuktikan kubus benar-benar mencapai state solved.
// POST /api/v1/solves/submit
{
  "session_id": "sess_98a7c2e1",
  "solve_id": "slv_01HZX87K",
  "scramble": "R U R' U' R' F R2 U' R' U' R U R' F'",
  "moves": [
    {"m": "F", "t": 420},
    {"m": "R", "t": 610},
    {"m": "U'", "t": 830},
    {"m": "R'", "t": 990}
  ],
  "elapsed_ms": 990
}

2. Validasi Integritas Notasi Langkah

Langkah ilegal harus digagalkan pada level parsing skema sebelum membebani engine simulasi cube. Notasi WCA yang valid untuk kubus 3x3x3 terdiri dari pergerakan face (U, D, L, R, F, B), double turn (akhiran 2), dan counter-clockwise (akhiran ').

Berikut contoh middleware validasi skema menggunakan Node.js dan TypeScript:

import { Request, Response, NextFunction } from 'express';

const WCA_MOVE_REGEX = /^[UDFBLR][2']?$/;
const MAX_MOVES_LIMIT = 300; // Standar batas toleransi langkah 3x3x3 manusiawi

interface MovePayload {
  m: string;
  t: number;
}

export function validateRubikMoves(req: Request, res: Response, next: NextFunction): void {
  const { moves, elapsed_ms } = req.body;

  if (!Array.isArray(moves) || moves.length === 0) {
    res.status(422).json({ error: 'INVALID_MOVES_PAYLOAD', message: 'Payload moves harus berupa non-empty array.' });
    return;
  }

  if (moves.length > MAX_MOVES_LIMIT) {
    res.status(422).json({ error: 'MOVE_COUNT_EXCEEDED', message: 'Jumlah langkah melampaui batas wajar.' });
    return;
  }

  let lastTime = 0;
  for (let i = 0; i < moves.length; i++) {
    const step: MovePayload = moves[i];

    if (!WCA_MOVE_REGEX.test(step.m)) {
      res.status(400).json({ error: 'ILLEGAL_NOTATION', message: `Notasi tidak valid pada index ${i}: ${step.m}` });
      return;
    }

    if (typeof step.t !== 'number' || step.t < lastTime) {
      res.status(400).json({ error: 'NON_MONOTONIC_TIMESTAMP', message: `Timestamp langkah non-kronologis pada index ${i}` });
      return;
    }

    lastTime = step.t;
  }

  if (lastTime > elapsed_ms) {
    res.status(400).json({ error: 'TIME_INCONSISTENCY', message: 'Timestamp langkah melebihi elapsed_ms.' });
    return;
  }

  next();
}

3. Mitigasi Race Replay via Idempotency Key

Retry otomatis dari mobile client saat koneksi buruk sering kali mengirim payload submisi ganda secara paralel. Tanpa idempotency, catatan waktu yang sama dapat tersimpan ganda atau memicu inkonsistensi leaderboard.

Gunakan Redis untuk implementasi distributed lock berbasis header Idempotency-Key:

import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';

const redis = new Redis();
const IDEMPOTENCY_TTL_SECONDS = 120;

export async function idempotencyMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
  const key = req.header('Idempotency-Key');
  if (!key) {
    res.status(400).json({ error: 'MISSING_IDEMPOTENCY_KEY', message: 'Header Idempotency-Key wajib disertakan.' });
    return;
  }

  const lockKey = `idempotency:lock:${key}`;
  const resultKey = `idempotency:result:${key}`;

  // Cek apakah response hasil pemrosesan sebelumnya sudah tersimpan di cache
  const cachedResponse = await redis.get(resultKey);
  if (cachedResponse) {
    const { status, body } = JSON.parse(cachedResponse);
    res.status(status).json(body);
    return;
  }

  // Pasang atomic lock dengan NX (Not Exists)
  const acquired = await redis.set(lockKey, 'in_flight', 'EX', IDEMPOTENCY_TTL_SECONDS, 'NX');
  if (!acquired) {
    res.status(409).json({ error: 'REQUEST_IN_FLIGHT', message: 'Permintaan sedang diproses. Jangan ulangi request.' });
    return;
  }

  // Tangkap response asli untuk disimpan sebelum dikembalikan ke client
  const originalJson = res.json.bind(res);
  res.json = (body: any) => {
    redis.set(resultKey, JSON.stringify({ status: res.statusCode, body }), 'EX', 86400);
    redis.del(lockKey);
    return originalJson(body);
  };

  next();
}

4. Skema Database Constraint Atomic untuk Pencatatan Skor

Validasi aplikasi harus didukung oleh integritas layer database. Database relasional seperti PostgreSQL memastikan tidak ada duplikasi solve untuk satu putaran turnamen menggunakan composite unique constraint.

CREATE TABLE competitions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title VARCHAR(100) NOT NULL
);

CREATE TABLE rounds (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    competition_id UUID REFERENCES competitions(id) ON DELETE CASCADE,
    round_number SMALLINT NOT NULL,
    UNIQUE(competition_id, round_number)
);

CREATE TABLE solves (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    round_id UUID REFERENCES rounds(id) ON DELETE CASCADE,
    user_id UUID NOT NULL,
    attempt_number SMALLINT NOT NULL CHECK (attempt_number BETWEEN 1 AND 5),
    elapsed_ms INTEGER NOT NULL CHECK (elapsed_ms > 0),
    moves_count SMALLINT NOT NULL CHECK (moves_count > 0),
    is_dnf BOOLEAN DEFAULT FALSE NOT NULL,
    is_plus_two BOOLEAN DEFAULT FALSE NOT NULL,
    idempotency_key VARCHAR(64) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
    -- Constraint unik: User hanya boleh memiliki 1 record per attempt pada round tertentu
    CONSTRAINT uq_user_round_attempt UNIQUE (round_id, user_id, attempt_number)
);
Catatan Produksi: Penggunaan composite unique constraint (round_id, user_id, attempt_number) memastikan transaksi database langsung menolak (rollback) race condition tingkat milidetik yang berhasil lolos dari distributed lock Redis.

5. Ringkasan Alur Verifikasi End-to-End

  1. Edge / Gateway: Periksa header Idempotency-Key via Redis SET NX. Tolak request concurrent dengan status HTTP 409.
  2. Middleware: Uji integritas array moves dengan regex WCA dan verifikasi timestamp yang monotonik naik. Tolak payload abnormal (HTTP 400/422).
  3. Simulation Engine: Eksekusi scramble diikuti oleh delta moves pada virtual cube. Pastikan semua facelet kembali ke kondisi solved.
  4. Persistence Layer: Eksekusi INSERT ke tabel solves dengan perlindungan unique key constraint untuk mengunci keabsahan ranking.