From 50ef39be4ebf901ba9c0bd7b87b1b93b4fab29a2 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 28 Dec 2025 11:02:45 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20TestRNG=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/logic/src/index.ts | 1 + packages/logic/src/util/TestRNG.ts | 284 ++++++++++++++++++++++++++ packages/logic/test/test-rngs.test.ts | 73 +++++++ 3 files changed, 358 insertions(+) create mode 100644 packages/logic/src/util/TestRNG.ts create mode 100644 packages/logic/test/test-rngs.test.ts diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 5371785..f665292 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -8,3 +8,4 @@ export * from './util/convertBytesLikeToUint8Array.js'; export * from './util/LiteHashDRBG.js'; export * from './util/RNG.js'; export * from './util/RandUtil.js'; +export * from './util/TestRNG.js'; diff --git a/packages/logic/src/util/TestRNG.ts b/packages/logic/src/util/TestRNG.ts new file mode 100644 index 0000000..9b1b378 --- /dev/null +++ b/packages/logic/src/util/TestRNG.ts @@ -0,0 +1,284 @@ +import type { RNG } from './RNG.js'; + +const maxSafeInt = Number.MAX_SAFE_INTEGER; + +const clamp01 = (value: number): number => { + if (value < 0) { + return 0; + } + if (value > 1) { + return 1; + } + return value; +}; + +// 테스트에서 0/1만 고정으로 뽑기 위한 RNG +export class ConstantRNG implements RNG { + private readonly bit: 0 | 1; + + public constructor(bit: 0 | 1) { + this.bit = bit; + } + + public getMaxInt(): number { + return maxSafeInt; + } + + public nextBytes(bytes: number): Uint8Array { + if (bytes <= 0) { + throw new Error('bytes must be positive'); + } + const result = new Uint8Array(bytes); + result.fill(this.bit === 0 ? 0x00 : 0xff); + return result; + } + + public nextBits(bits: number): Uint8Array { + if (bits <= 0) { + throw new Error('bits must be positive'); + } + const bytes = (bits + 7) >> 3; + const headBits = bits & 0x7; + const result = this.nextBytes(bytes); + + if (headBits === 0) { + return result; + } + + result[bytes - 1]! &= 0xff >> (8 - headBits); + return result; + } + + public nextInt(max?: number): number { + if (max === undefined || max === maxSafeInt) { + return this.bit === 0 ? 0 : maxSafeInt; + } + if (max > maxSafeInt) { + throw new Error('Over max int'); + } + if (max === 0) { + return 0; + } + if (max < 0) { + return -this.nextInt(-max); + } + return this.bit === 0 ? 0 : max; + } + + public nextFloat1(): number { + return this.bit; + } +} + +// 중간값 고정 + bool은 0/1 교대로 뽑는 RNG +export class MidpointRNG implements RNG { + private bitState: 0 | 1; + + public constructor(startBit: 0 | 1 = 0) { + this.bitState = startBit; + } + + public getMaxInt(): number { + return maxSafeInt; + } + + private nextBitRaw(): 0 | 1 { + const value = this.bitState; + this.bitState = value === 0 ? 1 : 0; + return value; + } + + public nextBytes(bytes: number): Uint8Array { + if (bytes <= 0) { + throw new Error('bytes must be positive'); + } + return this.nextBits(bytes * 8); + } + + public nextBits(bits: number): Uint8Array { + if (bits <= 0) { + throw new Error('bits must be positive'); + } + const bytes = (bits + 7) >> 3; + const result = new Uint8Array(bytes); + + for (let bitIdx = 0; bitIdx < bits; bitIdx += 1) { + if (this.nextBitRaw() === 0) { + continue; + } + const byteIdx = bitIdx >> 3; + const offset = bitIdx & 0x7; + result[byteIdx]! |= 1 << offset; + } + + return result; + } + + public nextInt(max?: number): number { + if (max === undefined || max === maxSafeInt) { + return Math.floor(maxSafeInt / 2); + } + if (max > maxSafeInt) { + throw new Error('Over max int'); + } + if (max === 0) { + return 0; + } + if (max < 0) { + return -this.nextInt(-max); + } + return Math.floor(max / 2); + } + + public nextFloat1(): number { + return 0.5; + } +} + +// 사인파 기반으로 주기/진폭을 조절하는 RNG +export class SineRNG implements RNG { + private step = 0; + private readonly period: number; + private readonly amplitude: number; + private readonly phase: number; + + public constructor(period = 32, amplitude = 0.5, phase = 0) { + if (period <= 0) { + throw new Error('period must be positive'); + } + this.period = period; + this.amplitude = amplitude; + this.phase = phase; + } + + public getMaxInt(): number { + return maxSafeInt; + } + + private nextWaveFloat(): number { + const radians = this.phase + (this.step * 2 * Math.PI) / this.period; + const value = 0.5 + this.amplitude * Math.sin(radians); + this.step += 1; + return clamp01(value); + } + + public nextBytes(bytes: number): Uint8Array { + if (bytes <= 0) { + throw new Error('bytes must be positive'); + } + const result = new Uint8Array(bytes); + for (let idx = 0; idx < bytes; idx += 1) { + const value = Math.floor(this.nextWaveFloat() * 256); + result[idx] = value >= 256 ? 255 : value; + } + return result; + } + + public nextBits(bits: number): Uint8Array { + if (bits <= 0) { + throw new Error('bits must be positive'); + } + const bytes = (bits + 7) >> 3; + const headBits = bits & 0x7; + const result = this.nextBytes(bytes); + + if (headBits === 0) { + return result; + } + result[bytes - 1]! &= 0xff >> (8 - headBits); + return result; + } + + public nextInt(max?: number): number { + if (max === undefined || max === maxSafeInt) { + const value = Math.floor(this.nextWaveFloat() * (maxSafeInt + 1)); + return value > maxSafeInt ? maxSafeInt : value; + } + if (max > maxSafeInt) { + throw new Error('Over max int'); + } + if (max === 0) { + return 0; + } + if (max < 0) { + return -this.nextInt(-max); + } + const value = Math.floor(this.nextWaveFloat() * (max + 1)); + return value > max ? max : value; + } + + public nextFloat1(): number { + return this.nextWaveFloat(); + } +} + +// 지정한 수열을 반복 재생하는 테스트용 RNG +export class SequenceRNG implements RNG { + private readonly sequence: number[]; + private idx = 0; + + public constructor(sequence: number[]) { + if (sequence.length === 0) { + throw new Error('sequence must not be empty'); + } + this.sequence = sequence.map(clamp01); + } + + public getMaxInt(): number { + return maxSafeInt; + } + + private nextValue(): number { + const value = this.sequence[this.idx]!; + this.idx = (this.idx + 1) % this.sequence.length; + return value; + } + + public nextBytes(bytes: number): Uint8Array { + if (bytes <= 0) { + throw new Error('bytes must be positive'); + } + const result = new Uint8Array(bytes); + for (let idx = 0; idx < bytes; idx += 1) { + const value = Math.floor(this.nextValue() * 256); + result[idx] = value >= 256 ? 255 : value; + } + return result; + } + + public nextBits(bits: number): Uint8Array { + if (bits <= 0) { + throw new Error('bits must be positive'); + } + const bytes = (bits + 7) >> 3; + const headBits = bits & 0x7; + const result = this.nextBytes(bytes); + if (headBits === 0) { + return result; + } + result[bytes - 1]! &= 0xff >> (8 - headBits); + return result; + } + + public nextInt(max?: number): number { + if (max === undefined || max === maxSafeInt) { + const value = Math.floor(this.nextValue() * (maxSafeInt + 1)); + return value > maxSafeInt ? maxSafeInt : value; + } + if (max > maxSafeInt) { + throw new Error('Over max int'); + } + if (max === 0) { + return 0; + } + if (max < 0) { + return -this.nextInt(-max); + } + const value = Math.floor(this.nextValue() * (max + 1)); + return value > max ? max : value; + } + + public nextFloat1(): number { + return this.nextValue(); + } +} diff --git a/packages/logic/test/test-rngs.test.ts b/packages/logic/test/test-rngs.test.ts new file mode 100644 index 0000000..b4143de --- /dev/null +++ b/packages/logic/test/test-rngs.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { ConstantRNG, MidpointRNG, SequenceRNG, SineRNG } from '../src/util/TestRNG.js'; + +const toArray = (bytes: Uint8Array): number[] => Array.from(bytes); + +describe('TestRNG:Constant', () => { + it('returns fixed 0', () => { + const rng = new ConstantRNG(0); + expect(rng.nextFloat1()).toBe(0); + expect(rng.nextInt(10)).toBe(0); + expect(toArray(rng.nextBytes(3))).toEqual([0, 0, 0]); + expect(toArray(rng.nextBits(3))).toEqual([0]); + }); + + it('returns fixed 1', () => { + const rng = new ConstantRNG(1); + expect(rng.nextFloat1()).toBe(1); + expect(rng.nextInt(10)).toBe(10); + expect(toArray(rng.nextBytes(2))).toEqual([255, 255]); + expect(toArray(rng.nextBits(3))).toEqual([7]); + }); +}); + +describe('TestRNG:Midpoint', () => { + it('returns midpoint for int/float', () => { + const rng = new MidpointRNG(); + expect(rng.nextFloat1()).toBe(0.5); + expect(rng.nextInt(9)).toBe(4); + expect(rng.nextInt(10)).toBe(5); + }); + + it('alternates bits', () => { + const rng = new MidpointRNG(); + expect(toArray(rng.nextBits(4))).toEqual([10]); + expect(toArray(rng.nextBits(4))).toEqual([10]); + }); +}); + +describe('TestRNG:Sine', () => { + it('follows sine wave with period/amplitude', () => { + const rng = new SineRNG(4, 0.5, 0); + expect(rng.nextFloat1()).toBeCloseTo(0.5, 8); + expect(rng.nextFloat1()).toBeCloseTo(1, 8); + expect(rng.nextFloat1()).toBeCloseTo(0.5, 8); + expect(rng.nextFloat1()).toBeCloseTo(0, 8); + }); + + it('maps float to int range', () => { + const rng = new SineRNG(4, 0.5, 0); + expect(rng.nextInt(9)).toBe(5); + expect(rng.nextInt(9)).toBe(9); + }); +}); + +describe('TestRNG:Sequence', () => { + it('cycles fixed sequence', () => { + const rng = new SequenceRNG([0, 0.25, 0.5, 0.75, 1]); + expect(rng.nextFloat1()).toBe(0); + expect(rng.nextFloat1()).toBe(0.25); + expect(rng.nextFloat1()).toBe(0.5); + expect(rng.nextFloat1()).toBe(0.75); + expect(rng.nextFloat1()).toBe(1); + expect(rng.nextFloat1()).toBe(0); + }); + + it('converts sequence to bytes and ints', () => { + const rng = new SequenceRNG([0, 0.5, 1]); + expect(toArray(rng.nextBytes(3))).toEqual([0, 128, 255]); + expect(rng.nextInt(8)).toBe(0); + expect(rng.nextInt(8)).toBe(4); + expect(rng.nextInt(8)).toBe(8); + }); +});