diff --git a/src/Illuminate/Support/Lottery.php b/src/Illuminate/Support/Lottery.php new file mode 100644 index 000000000000..1cc87d489765 --- /dev/null +++ b/src/Illuminate/Support/Lottery.php @@ -0,0 +1,258 @@ + 1) { + throw new RuntimeException('Float must not be greater than 1.'); + } + + $this->chances = $chances; + + $this->outOf = $outOf; + } + + /** + * Create a new Lottery instance. + * + * @param int|float $chances + * @param ?int $outOf + * @return static + */ + public static function odds($chances, $outOf = null) + { + return new static($chances, $outOf); + } + + /** + * Set the winner callback. + * + * @param callable $callback + * @return $this + */ + public function winner($callback) + { + $this->winner = $callback; + + return $this; + } + + /** + * Set the loser callback. + * + * @param callable $callback + * @return $this + */ + public function loser($callback) + { + $this->loser = $callback; + + return $this; + } + + /** + * Run the lottery. + * + * @param mixed ...$args + * @return mixed + */ + public function __invoke(...$args) + { + return $this->runCallback(...$args); + } + + /** + * Run the lottery. + * + * @param null|int $times + * @return mixed + */ + public function choose($times = null) + { + if ($times === null) { + return $this->runCallback(); + } + + $results = []; + + for ($i = 0; $i < $times; $i++) { + $results[] = $this->runCallback(); + } + + return $results; + } + + /** + * Run the winner or loser callback, randomly. + * + * @param mixed ...$args + * @return callable + */ + protected function runCallback(...$args) + { + return $this->wins() + ? ($this->winner ?? fn () => true)(...$args) + : ($this->loser ?? fn () => false)(...$args); + } + + /** + * Determine if the lottery "wins" or "loses". + * + * @return bool + */ + protected function wins() + { + return static::resultFactory()($this->chances, $this->outOf); + } + + /** + * The factory that determines the lottery result. + * + * @return callable + */ + protected static function resultFactory() + { + return static::$resultFactory ?? fn ($chances, $outOf) => $outOf === null + ? random_int(0, PHP_INT_MAX) / PHP_INT_MAX <= $chances + : random_int(1, $outOf) <= $chances; + } + + /** + * Force the lottery to always result in a win. + * + * @param callable|null $callback + * @return void + */ + public static function alwaysWin($callback = null) + { + self::setResultFactory(fn () => true); + + if ($callback === null) { + return; + } + + $callback(); + + static::determineResultNormally(); + } + + /** + * Force the lottery to always result in a lose. + * + * @param callable|null $callback + * @return void + */ + public static function alwaysLose($callback = null) + { + self::setResultFactory(fn () => false); + + if ($callback === null) { + return; + } + + $callback(); + + static::determineResultNormally(); + } + + /** + * Set the sequence that will be used to determine lottery results. + * + * @param array $sequence + * @param callable|null $whenMissing + * @return void + */ + public static function forceResultWithSequence($sequence, $whenMissing = null) + { + $next = 0; + + $whenMissing ??= function ($chances, $outOf) use (&$next) { + $factoryCache = static::$resultFactory; + + static::$resultFactory = null; + + $result = static::resultFactory()($chances, $outOf); + + static::$resultFactory = $factoryCache; + + $next++; + + return $result; + }; + + static::setResultFactory(function ($chances, $outOf) use (&$next, $sequence, $whenMissing) { + if (array_key_exists($next, $sequence)) { + return $sequence[$next++]; + } + + return $whenMissing($chances, $outOf); + }); + } + + /** + * Indicate that the lottery results should be determined normally. + * + * @return void + */ + public static function determineResultNormally() + { + static::$resultFactory = null; + } + + /** + * Set the factory that should be used to deterine the lottery results. + * + * @param callable $factory + * @return void + */ + public static function setResultFactory($factory) + { + self::$resultFactory = $factory; + } +} diff --git a/tests/Support/LotteryTest.php b/tests/Support/LotteryTest.php new file mode 100644 index 000000000000..ca0d5638b890 --- /dev/null +++ b/tests/Support/LotteryTest.php @@ -0,0 +1,184 @@ +winner(function () use (&$wins) { + $wins = true; + })->choose(); + + $this->assertTrue($wins); + } + + public function testItCanLose() + { + $wins = false; + $loses = false; + + Lottery::odds(0, 1) + ->winner(function () use (&$wins) { + $wins = true; + })->loser(function () use (&$loses) { + $loses = true; + })->choose(); + + $this->assertFalse($wins); + $this->assertTrue($loses); + } + + public function testItCanReturnValues() + { + $win = Lottery::odds(1, 1)->winner(fn () => 'win')->choose(); + $this->assertSame('win', $win); + + $lose = Lottery::odds(0, 1)->loser(fn () => 'lose')->choose(); + $this->assertSame('lose', $lose); + } + + public function testItCanChooseSeveralTimes() + { + $results = Lottery::odds(1, 1)->winner(fn () => 'win')->choose(2); + $this->assertSame(['win', 'win'], $results); + + $results = Lottery::odds(0, 1)->loser(fn () => 'lose')->choose(2); + $this->assertSame(['lose', 'lose'], $results); + } + + public function testItCanBePassedAsCallable() + { + // Exmaple... + // DB::whenQueryingForLongerThan(Interval::seconds(5), Lottery::odds(1, 5)->winner(function ($connection) { + // Alert the team + // })); + $result = (function (callable $callable) { + return $callable('winner-chicken', '-dinner'); + })(Lottery::odds(1, 1)->winner(fn ($first, $second) => 'winner-'.$first.$second)); + + $this->assertSame('winner-winner-chicken-dinner', $result); + } + + public function testWithoutSpecifiedClosuresBooleansAreReturned() + { + $win = Lottery::odds(1, 1)->choose(); + $this->assertTrue($win); + + $lose = Lottery::odds(0, 1)->choose(); + $this->assertFalse($lose); + } + + public function testItCanForceWinningResultInTests() + { + $result = null; + Lottery::alwaysWin(function () use (&$result) { + $result = Lottery::odds(1, 2)->winner(fn () => 'winner')->choose(10); + }); + + $this->assertSame([ + 'winner', 'winner', 'winner', 'winner', 'winner', + 'winner', 'winner', 'winner', 'winner', 'winner', + ], $result); + } + + public function testItCanForceLosingResultInTests() + { + $result = null; + Lottery::alwaysLose(function () use (&$result) { + $result = Lottery::odds(1, 2)->loser(fn () => 'loser')->choose(10); + }); + + $this->assertSame([ + 'loser', 'loser', 'loser', 'loser', 'loser', + 'loser', 'loser', 'loser', 'loser', 'loser', + ], $result); + } + + public function testItCanForceTheResultViaSequence() + { + $result = null; + Lottery::forceResultWithSequence([ + true, false, true, false, true, + false, true, false, true, false, + ]); + + $result = Lottery::odds(1, 100)->winner(fn () => 'winner')->loser(fn () => 'loser')->choose(10); + + $this->assertSame([ + 'winner', 'loser', 'winner', 'loser', 'winner', + 'loser', 'winner', 'loser', 'winner', 'loser', + ], $result); + } + + public function testItCanHandleMissingSequenceItems() + { + $result = null; + Lottery::forceResultWithSequence([ + 0 => true, + 1 => true, + // 2 => ... + 3 => true, + ], fn () => throw new RuntimeException('Missing key in sequence.')); + + $result = Lottery::odds(1, 10000)->winner(fn () => 'winner')->loser(fn () => 'loser')->choose(); + $this->assertSame('winner', $result); + + $result = Lottery::odds(1, 10000)->winner(fn () => 'winner')->loser(fn () => 'loser')->choose(); + $this->assertSame('winner', $result); + + $this->expectException(RuntimeException::class); + $this->expectErrorMessage('Missing key in sequence.'); + Lottery::odds(1, 10000)->winner(fn () => 'winner')->loser(fn () => 'loser')->choose(); + } + + public function testItThrowsForFloatsOverOne() + { + $this->expectException(RuntimeException::class); + $this->expectErrorMessage('Float must not be greater than 1.'); + + new Lottery(1.1); + } + + public function testItCanWinWithFloat() + { + $wins = false; + + Lottery::odds(1.0) + ->winner(function () use (&$wins) { + $wins = true; + })->choose(); + + $this->assertTrue($wins); + } + + public function testItCanLoseWithFloat() + { + $wins = false; + $loses = false; + + Lottery::odds(0.0) + ->winner(function () use (&$wins) { + $wins = true; + })->loser(function () use (&$loses) { + $loses = true; + })->choose(); + + $this->assertFalse($wins); + $this->assertTrue($loses); + } +}