Équité Vérifiable

Entrez les détails du jeu pour vérifier vos résultats

Code

Choisissez un jeu ci-dessus pour inspecter le code source qui produit ses résultats.

import { createHmac } from 'crypto';
import Decimal from 'decimal.js';

const UINT32_MAX = 0x100000000; // 2^32

/**
 * Generates an infinite deterministic stream of cryptographically secure
 * 32-bit unsigned integers derived from HMAC-SHA256.
 *
 * @param key - Secret cryptographic key (server seed). Must remain private until revealed.
 * @param message - Public input string (e.g., client seed, nonce, or domain label).
 *
 * @remarks
 * - Uses HMAC-SHA256 as a pseudorandom function (PRF) to produce a reproducible
 *   and verifiable sequence of numbers.
 * - `step` ensures each 32-bit block is derived from a unique input.
 * - Output is split into 32-bit chunks, suitable for unbiased integer or bit-level
 *   extraction.
 * - Bits are consumed starting from the Most Significant Bit (MSB) to preserve
 *   full numeric precision and maximum entropy.
 * - Fully deterministic: anyone with the same key and message can verify the results.
 */
function* getUint32Stream(key: string, message: string): Iterator<number> {
  let step = 0;

  while (true) {
    const hmac = createHmac('sha256', key);
    hmac.update(`${message}:${step}`);
    const hash = hmac.digest(); // 32 bytes (256 bits)

    for (let i = 0; i < hash.length; i += 4) {
      yield (
        (hash[i] << 24) |
        (hash[i + 1] << 16) |
        (hash[i + 2] << 8) |
        hash[i + 3]
      ) >>> 0; // force unsigned 32-bit
    }

    step++;
  }
}

/**
 * Converts a 32-bit random number into an unbiased integer within [0, range).
 *
 * @param uint32StreamFactory - Function that returns a stream of 32-bit integers.
 * @param range - The desired upper bound (exclusive) for the output.
 *
 * @remarks
 * - Uses rejection sampling to avoid modulo bias.
 * - Every integer within the range is equally likely.
 * - Critical for fair gameplay and provably fair verification.
 */
function getUnbiasedInt(uint32StreamFactory: () => Iterator<number>, range: number) {
  if (!Number.isInteger(range) || range <= 0) {
    throw new Error('Invalid range');
  }

  const maxAcceptable = UINT32_MAX - (UINT32_MAX % range);
  const stream = uint32StreamFactory();
  let next = stream.next();

  while (!next.done) {
    const value = next.value;
    if (value < maxAcceptable) {
      return value % range;
    }
    next = stream.next();
  }

  throw new Error('Failed to generate unbiased value: entropy stream exhausted');
}

const MAX_WEIGHT_DECIMALS = 6;
const WEIGHT_SCALE = 10 ** MAX_WEIGHT_DECIMALS;

function countWeightDecimals(n: number) {
  const s = n.toString();

  if (!s.includes('.')) return 0;

  return s.length - s.indexOf('.') - 1;
}

/**
 * Picks a value from a list of weighted segments fairly.
 *
 * @param getUint32Stream - Function returning a cryptographically secure 32-bit stream.
 * @param segments - Array of { value, weight } objects where higher weight = higher chance.
 *
 * @remarks
 * - Weights are scaled to prevent floating-point errors.
 * - Uses `getUnbiasedInt` to avoid bias in weighted selection.
 * - Ensures that rare outcomes are as rare as intended.
 * - Suitable for Rain Mode or any multiplier distribution in the game.
 */
function pickWeighted<T>(getUint32Stream: () => Iterator<number>, segments: { value: T; weight: number }[]) {
  if (!segments.length) throw new Error('No segments provided');

  const scaledWeights: number[] = [];
  let totalWeight = 0;

  for (const { value, weight } of segments) {
    if (!Number.isFinite(weight) || weight <= 0) throw new Error(`Invalid weight for value ${value}`);
    const decimals = countWeightDecimals(weight);
    if (decimals > MAX_WEIGHT_DECIMALS) throw new Error(`Weight for value ${value} exceeds max ${MAX_WEIGHT_DECIMALS} decimals`);

    const scaled = new Decimal(weight).mul(WEIGHT_SCALE).toNumber();
    if (!Number.isInteger(scaled)) throw new Error(`Invalid precision for value ${value}`);

    scaledWeights.push(scaled);
    totalWeight += scaled;
    if (!Number.isSafeInteger(totalWeight)) throw new Error('Total weight exceeds safe integer');
  }

  const ticket = getUnbiasedInt(getUint32Stream, totalWeight);

  let cumulative = 0;
  for (let i = 0; i < scaledWeights.length; i++) {
    cumulative += scaledWeights[i];
    if (ticket < cumulative) return segments[i].value;
  }

  throw new Error('Weighted selection logic failure');
}

const WHEEL_CONFIGS = {
  low: [
    { multiplier: 0, weight: 20 }, // 20% - Loss
    { multiplier: 1.1, weight: 44 }, // 44%
    { multiplier: 1.2, weight: 24 }, // 24%
    { multiplier: 1.4, weight: 8 }, // 8%
    { multiplier: 2.0, weight: 4 }, // 4%
  ],
  medium: [
    { multiplier: 0, weight: 40 }, // 40% - Loss
    { multiplier: 1.2, weight: 32 }, // 32%
    { multiplier: 1.5, weight: 16 }, // 16%
    { multiplier: 2.2, weight: 8 }, // 8%
    { multiplier: 4.0, weight: 4 }, // 4%
  ],
  high: [
    { multiplier: 0, weight: 60 }, // 60% - Loss
    { multiplier: 1.5, weight: 16 }, // 16%
    { multiplier: 2.0, weight: 12 }, // 12%
    { multiplier: 3.0, weight: 8 }, // 8%
    { multiplier: 6.0, weight: 4 }, // 4%
  ],
  risky: [
    { multiplier: 0, weight: 84 }, // 84% - Loss
    { multiplier: 2.0, weight: 4 }, // 4%
    { multiplier: 3.0, weight: 4 }, // 4%
    { multiplier: 4.0, weight: 4 }, // 4%
    { multiplier: 15.0, weight: 4 }, // 4%
  ],
};

function determineResultDetails(options: {
  difficulty: 'low' | 'medium' | 'high' | 'risky';
  serverSeed: string;
  clientSeed: string;
  round: number;
  nonce: number;
}) {
  const config = WHEEL_CONFIGS[options.difficulty]!;

  const uint32Generator =
    getUint32Stream(options.serverSeed, `${options.clientSeed}:${options.nonce}:${options.round}:wheel`);

  return pickWeighted(
    () => uint32Generator,
    config.map((m) => ({ value: m.multiplier, weight: m.weight }))
  );
}

console.log(
  determineResultDetails({
    difficulty: 'low',
    clientSeed: 'wheel-test',
    serverSeed: 'wheel-test',
    nonce: 0,
    round: 1,
  })
)