可验证公正
输入游戏详细信息以验证您的结果
代码
选择以上的游戏以检查产生其结果的源代码。
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');
}
/**
* Generates the sequence of left/right moves for a Plinko ball.
*
* @param getUint32Stream - Function returning a cryptographically secure 32-bit stream.
* @param rows - Number of rows in the Plinko board.
* @returns Array of 0|1 where 0 = left, 1 = right.
*
* @remarks
* - Each move is determined by a single bit extracted from the 32-bit stream.
* - Bits are consumed starting from the MSB to maintain maximum precision.
* - Fully deterministic for given seeds and nonce, enabling provable fairness.
* - The distribution of left/right moves is perfectly uniform.
*/
function generatePlinkoPath(getUint32Stream: () => Iterator<number>, rows: number) {
if (!Number.isInteger(rows) || rows <= 0) throw new Error('Invalid rows');
const path: (0 | 1)[] = [];
const stream = getUint32Stream();
let buffer = 0;
let bitsAvailable = 0;
while (path.length < rows) {
if (bitsAvailable === 0) {
const next = stream.next();
if (next.done) throw new Error('Entropy stream exhausted');
buffer = next.value;
bitsAvailable = 32;
}
const bit = (buffer >>> (bitsAvailable - 1)) & 1;
path.push(bit as 0 | 1);
bitsAvailable--;
}
return path;
}
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');
}
interface BallDetail {
ballPath: (0 | 1)[];
bucketIndex: number;
bucketMultiplier: number;
rouletteMultiplier: number | null;
accumulatedMultiplier: number;
}
// Adjust desired PLINKO_CONFIGS
const PLINKO_CONFIGS = {
low: [{
rowCount: 8,
multipliers: [1, 1, 1, 1, 1, 1, 1, 1, 1],
}],
medium: [{
rowCount: 8,
multipliers: [1, 1, 1, 1, 1, 1, 1, 1, 1],
}],
high: [{
rowCount: 8,
multipliers: [1, 1, 1, 1, 1, 1, 1, 1, 1],
}],
rain: [{
rowCount: 8,
multipliers: [1, -1, 1, 1, 1, 1, 1, -1, 1],
}]
} satisfies Record<'low' | 'medium' | 'high' | 'rain', { rowCount: number; multipliers: number[] }[]>;
// Adjust desired PLINKO_RAIN_MULTIPLIERS
const PLINKO_RAIN_MULTIPLIERS = [
{ multiplier: 1, weight: 50 },
{ multiplier: 1, weight: 50 }
] satisfies { multiplier: number; weight: number }[];
/**
* Calculates the outcome of a Plinko drop, including multipliers and rain mode logic.
*
* @remarks
* - Determines the Plinko ball path using `getBallPath` with seeds and nonce.
* - Calculates which bucket the ball lands in (bucketIndex) by summing left/right moves.
* - Fetches the multiplier for that bucket from `PLINKO_CONFIGS`.
* - Accumulates multipliers if multiple balls have been played.
* - Handles Rain Mode:
* - Buckets with multiplier `-1` trigger a weighted random multiplier from `PLINKO_RAIN_MULTIPLIERS`.
* - Recursively calls itself up to a maximum of 50 rounds to prevent stack overflow.
* - All randomness comes from cryptographically secure `getUint32Stream`.
* - Fully deterministic for given seeds and nonce, allowing provably fair verification.
* - Throws descriptive errors if:
* - rowCount has no matching configuration
* - recursion depth exceeds limit
* - multiplier is undefined
*/
function determineResultDetails(options: {
difficulty: 'low' | 'medium' | 'high' | 'rain';
rowCount: number;
roundIndex: number;
serverSeed: string;
clientSeed: string;
nonce: number;
ballDetails: BallDetail[];
}) {
if (options.roundIndex > 50) {
throw new Error('Rain recursion overflow');
}
const lastBallDetail = options.ballDetails[options.ballDetails.length - 1];
const isRainMode = options.difficulty === 'rain';
let totalNonce: string = options.nonce.toString();
if (isRainMode) {
totalNonce += `:${options.roundIndex}`;
}
const normalModeUint32Generator = getUint32Stream(options.serverSeed, `${options.clientSeed}:${totalNonce}:plinko`);
const ballPath = generatePlinkoPath(
() => normalModeUint32Generator,
options.rowCount
);
const segment = PLINKO_CONFIGS[options.difficulty].find(
(segment) => segment.rowCount === options.rowCount
);
if (!segment) {
throw new Error(
`Could not find multipliers for rowCount=${options.rowCount} and difficulty=${options.difficulty}`
);
}
const bucketIndex = ballPath.reduce<number>((acc, init) => acc + init, 0);
const bucketMultiplier = segment.multipliers[bucketIndex];
if (bucketMultiplier == null) {
throw new Error(
`Could not find multiplier for bucketIndex=${bucketIndex}`
);
}
if (bucketMultiplier !== -1) {
const accumulatedMultiplier = lastBallDetail
? Decimal(lastBallDetail.accumulatedMultiplier)
.mul(bucketMultiplier)
.toDecimalPlaces(2, Decimal.ROUND_FLOOR)
.toNumber()
: bucketMultiplier;
options.ballDetails.push({
ballPath: ballPath,
bucketIndex,
bucketMultiplier,
accumulatedMultiplier,
rouletteMultiplier: null,
});
return options.ballDetails;
}
const rainModeRouletteUint32Generator =
getUint32Stream(options.serverSeed, `${options.clientSeed}:${options.nonce}:${options.roundIndex}:rain:plinko`);
const rainMultiplier = pickWeighted(
() => rainModeRouletteUint32Generator,
PLINKO_RAIN_MULTIPLIERS.map((m) => ({ value: m.multiplier, weight: m.weight }))
);
const accumulatedMultiplier = lastBallDetail
? Decimal(lastBallDetail.accumulatedMultiplier)
.mul(rainMultiplier)
.toDecimalPlaces(2, Decimal.ROUND_FLOOR)
.toNumber()
: rainMultiplier;
options.ballDetails.push({
ballPath: ballPath,
bucketIndex,
bucketMultiplier,
accumulatedMultiplier,
rouletteMultiplier: rainMultiplier,
});
return determineResultDetails({
...options,
roundIndex: options.roundIndex + 1,
});
}
// console.log(
// determineResultDetails({
// difficulty: 'low',
// rowCount: 8,
// clientSeed: 'player_input_client_seed',
// serverSeed: 'player_input_server_seed',
// nonce: 0,
// roundIndex: 0,
// ballDetails: []
// })
// )