/* * FairDraw — 公平隨機抽選 * MIT License · Copyright (c) 2026 恩宇數位科技有限公司 * * 這是「免費單機抽獎系統」實際用來抽出得獎者的程式,完整公開。 * 本檔案零相依:不引用任何框架或套件,不碰畫面,只用瀏覽器 / Node 內建的 crypto。 * 可以直接貼到瀏覽器 console 或 Node 執行。 * * 做法: * 1. 亂數來自 crypto.getRandomValues(作業系統等級的亂數,不是 Math.random)。 * 2. 取整數時用「拒絕取樣」,避免 `亂數 % 範圍` 造成的模數偏差。 * 3. 抽選用 Fisher-Yates 洗牌:每個候選被抽中的機率完全相同。 * * 這個類別沒有加權、保底或指定得獎者的任何入口。 */ /** 亂數來源:只要能把 Uint32Array 填滿即可,預設用 crypto */ export interface RandomSource { fill(buffer: Uint32Array): void; } export const cryptoRandomSource: RandomSource = { fill: (buffer) => { globalThis.crypto.getRandomValues(buffer); }, }; export class FairDraw { private readonly pool = new Uint32Array(64); private cursor = this.pool.length; constructor(private readonly source: RandomSource = cryptoRandomSource) {} /** 回傳 0 ≤ n < maxExclusive 的整數,每個值機率相同 */ nextInt(maxExclusive: number): number { if (!Number.isInteger(maxExclusive) || maxExclusive <= 0 || maxExclusive > 0x100000000) { throw new RangeError('maxExclusive 必須是 1 到 2^32 的整數'); } if (maxExclusive === 1) { return 0; } // 2^32 以內能被 maxExclusive 整除的最大範圍;超出的亂數丟掉重取,每個餘數才會等機率 const limit = 0x100000000 - (0x100000000 % maxExclusive); for (;;) { const r = this.nextUint32(); if (r < limit) { return r % maxExclusive; } } } /** Fisher-Yates 完整洗牌。回傳新陣列,不改動輸入 */ shuffle(items: readonly T[]): T[] { const arr = items.slice(); for (let i = arr.length - 1; i > 0; i--) { const j = this.nextInt(i + 1); const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } return arr; } /** * 從候選中抽出 count 位。 * 部分 Fisher-Yates:只做 count 次交換,結果與完整洗牌後取前 count 位相同。 * count 大於候選數時回傳全部(順序隨機)。 */ pick(candidates: readonly T[], count: number): T[] { const arr = candidates.slice(); const n = Math.max(0, Math.min(Math.floor(count), arr.length)); for (let i = 0; i < n; i++) { const j = i + this.nextInt(arr.length - i); const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } return arr.slice(0, n); } private nextUint32(): number { if (this.cursor >= this.pool.length) { this.source.fill(this.pool); this.cursor = 0; } return this.pool[this.cursor++]; } }