44
55namespace Relaticle \Flowforge \Services ;
66
7+ use InvalidArgumentException ;
8+
79/**
810 * Decimal-based position calculation using BCMath for precision.
911 * Uses DECIMAL(20,10) storage - 10 integer digits + 10 decimal places.
1517 * a unique position to prevent collisions when multiple users move cards
1618 * to the same position simultaneously.
1719 */
18- final class DecimalPosition
20+ final readonly class DecimalPosition
1921{
2022 /**
2123 * Default gap between positions (65,535).
@@ -76,10 +78,18 @@ public static function before(string $position): string
7678 *
7779 * @param string $after Lower bound position (card above)
7880 * @param string $before Upper bound position (card below)
79- * @return string Position between bounds with jitter applied
81+ * @return string Position between bounds with jitter applied
82+ *
83+ * @throws InvalidArgumentException When after >= before (invalid bounds)
8084 */
8185 public static function between (string $ after , string $ before ): string
8286 {
87+ if (bccomp ($ after , $ before , self ::SCALE ) >= 0 ) {
88+ throw new InvalidArgumentException (
89+ "Invalid bounds: after ( {$ after }) must be less than before ( {$ before }) "
90+ );
91+ }
92+
8393 // Calculate the exact midpoint
8494 $ sum = bcadd ($ after , $ before , self ::SCALE );
8595 $ midpoint = bcdiv ($ sum , '2 ' , self ::SCALE );
@@ -106,10 +116,18 @@ public static function between(string $after, string $before): string
106116 *
107117 * @param string $after Lower bound position
108118 * @param string $before Upper bound position
109- * @return string Exact midpoint between bounds
119+ * @return string Exact midpoint between bounds
120+ *
121+ * @throws InvalidArgumentException When after >= before (invalid bounds)
110122 */
111123 public static function betweenExact (string $ after , string $ before ): string
112124 {
125+ if (bccomp ($ after , $ before , self ::SCALE ) >= 0 ) {
126+ throw new InvalidArgumentException (
127+ "Invalid bounds: after ( {$ after }) must be less than before ( {$ before }) "
128+ );
129+ }
130+
113131 $ sum = bcadd ($ after , $ before , self ::SCALE );
114132
115133 return bcdiv ($ sum , '2 ' , self ::SCALE );
@@ -172,7 +190,7 @@ public static function generateSequence(int $count): array
172190 * Normalize a position string to ensure consistent format.
173191 * Converts numeric values to properly scaled decimal strings.
174192 */
175- public static function normalize (string | int | float $ position ): string
193+ public static function normalize (string | int | float $ position ): string
176194 {
177195 return bcadd ((string ) $ position , '0 ' , self ::SCALE );
178196 }
@@ -220,7 +238,7 @@ public static function gap(string $lower, string $upper): string
220238 * @param string $after Lower bound position
221239 * @param string $before Upper bound position
222240 * @param int $count Number of positions to generate
223- * @return array<int, string> Array of unique positions
241+ * @return array<int, string> Array of unique positions
224242 */
225243 public static function generateBetween (string $ after , string $ before , int $ count ): array
226244 {
@@ -252,7 +270,7 @@ public static function generateBetween(string $after, string $before, int $count
252270 * desired range using BCMath for precision.
253271 *
254272 * @param string $maxOffset Maximum absolute offset (positive number)
255- * @return string Random value in [-maxOffset, +maxOffset]
273+ * @return string Random value in [-maxOffset, +maxOffset]
256274 */
257275 private static function generateJitter (string $ maxOffset ): string
258276 {
@@ -261,14 +279,19 @@ private static function generateJitter(string $maxOffset): string
261279 return '0.0000000000 ' ;
262280 }
263281
264- // Get 8 random bytes and convert to unsigned 64-bit integer
282+ // Get 8 random bytes and convert to unsigned 64-bit string
283+ // PHP's unpack('P') returns signed int for values >= 2^63,
284+ // so we manually convert bytes to an unsigned decimal string
265285 $ bytes = random_bytes (8 );
266- $ randomInt = unpack ('P ' , $ bytes )[1 ]; // Unsigned 64-bit little-endian
286+ $ randomUnsigned = '0 ' ;
287+ for ($ i = 7 ; $ i >= 0 ; $ i --) {
288+ $ randomUnsigned = bcmul ($ randomUnsigned , '256 ' , 0 );
289+ $ randomUnsigned = bcadd ($ randomUnsigned , (string ) ord ($ bytes [$ i ]), 0 );
290+ }
267291
268- // Normalize to [0, 1] range
269- // PHP_INT_MAX is the max for signed, but we have unsigned so use 2^64
292+ // Normalize to [0, 1] range using 2^64 - 1 as max
270293 $ maxUint64 = '18446744073709551615 ' ; // 2^64 - 1
271- $ normalized = bcdiv (( string ) $ randomInt , $ maxUint64 , self ::SCALE );
294+ $ normalized = bcdiv ($ randomUnsigned , $ maxUint64 , self ::SCALE );
272295
273296 // Scale to [-1, 1] range
274297 $ scaled = bcsub (bcmul ($ normalized , '2 ' , self ::SCALE ), '1 ' , self ::SCALE );
0 commit comments