diff --git a/dist/app/Classes/Factory/TimedChallengeFactory.php b/dist/app/Classes/Factory/TimedChallengeFactory.php new file mode 100644 index 0000000..cd99ebd --- /dev/null +++ b/dist/app/Classes/Factory/TimedChallengeFactory.php @@ -0,0 +1,167 @@ + 1, + '/Game/Challenges/Daily/Challenge_Domination_Runner.Challenge_Domination_Runner' => 1, + '/Game/Challenges/Weekly/Challenge_Emotional_RunnerWeekly.Challenge_Emotional_RunnerWeekly' => 3, + '/Game/Challenges/Weekly/Challenge_Shields_RunnerWeekly.Challenge_Shields_RunnerWeekly' => 10 + ] ; + + const AVAILABLE_DAILY_HUNTER = [ + '/Game/Challenges/Daily/Challenge_Domination_Hunter.Challenge_Domination_Hunter' => 1, + '/Game/Challenges/Weekly/Challenge_DroneActivation_HunterWeekly.Challenge_DroneActivation_HunterWeekly' => 5, + '/Game/Challenges/Weekly/Challenge_Emotional_HunterWeekly.Challenge_Emotional_HunterWeekly' => 3, + '/Game/Challenges/Weekly/Challenge_Headshot_HunterWeekly.Challenge_Headshot_HunterWeekly' => 1, + '/Game/Challenges/Weekly/Challenge_InDenial_HunterWeekly.Challenge_InDenial_HunterWeekly' => 3, + ]; + + const AVAILABLE_WEEKLY_RUNNER = [ + '/Game/Challenges/Weekly/Challenge_BleedOut_RunnerWeekly.Challenge_BleedOut_RunnerWeekly' => 5, + '/Game/Challenges/Weekly/Challenge_Emotional_RunnerWeekly.Challenge_Emotional_RunnerWeekly' => 10, + '/Game/Challenges/Weekly/Challenge_Greed_RunnerWeekly.Challenge_Greed_RunnerWeekly' => 50, + '/Game/Challenges/Weekly/Challenge_Shields_RunnerWeekly.Challenge_Shields_RunnerWeekly' => 100, + '/Game/Challenges/Weekly/Challenge_SpeedCapture_RunnerWeekly.Challenge_SpeedCapture_RunnerWeekly' => 15, + '/Game/Challenges/Weekly/Challenge_UPs_RunnerWeekly.Challenge_UPs_RunnerWeekly' => 5, + '/Game/Challenges/Weekly/Challenge_Wasteful_RunnerWeekly.Challenge_Wasteful_RunnerWeekly' => 100 + ]; + + const AVAILABLE_WEEKLY_HUNTER = [ + '/Game/Challenges/Weekly/Challenge_ARB_Damage_HunterWeekly.Challenge_ARB_Damage_HunterWeekly' => 5000, + '/Game/Challenges/Weekly/Challenge_AssaultRifleWins_HunterWeekly.Challenge_AssaultRifleWins_HunterWeekly' => 100, + '/Game/Challenges/Weekly/Challenge_Damage_HunterWeekly.Challenge_Damage_HunterWeekly' => 5000, + '/Game/Challenges/Weekly/Challenge_DroneActivation_HunterWeekly.Challenge_DroneActivation_HunterWeekly' => 25, + '/Game/Challenges/Weekly/Challenge_Emotional_HunterWeekly.Challenge_Emotional_HunterWeekly' => 10, + '/Game/Challenges/Weekly/Challenge_Greed_HunterWeekly.Challenge_Greed_HunterWeekly' => 50, + '/Game/Challenges/Weekly/Challenge_Headshot_HunterWeekly.Challenge_Headshot_HunterWeekly' => 10, + '/Game/Challenges/Weekly/Challenge_HuntingShotgunWins_HunterWeekly.Challenge_HuntingShotgunWins_HunterWeekly' => 10, + '/Game/Challenges/Weekly/Challenge_InDenial_HunterWeekly.Challenge_InDenial_HunterWeekly' => 20, + '/Game/Challenges/Weekly/Challenge_LMGWins_HunterWeekly.Challenge_LMGWins_HunterWeekly' => 10, + '/Game/Challenges/Weekly/Challenge_Reveals_hunterWeekly.Challenge_Reveals_hunterWeekly' => 10, + '/Game/Challenges/Weekly/Challenge_RingOut_hunterWeekly.Challenge_RingOut_hunterWeekly' => 5, + '/Game/Challenges/Weekly/Challenge_Mines_HunterWeekly.Challenge_Mines_HunterWeekly' => 10, + '/Game/Challenges/Weekly/Challenge_ShotgunDowns_HunterWeekly.Challenge_ShotgunDowns_HunterWeekly' => 10, + '/Game/Challenges/Weekly/Challenge_Wasteful_HunterWeekly.Challenge_Wasteful_HunterWeekly' => 100, + ]; + + const DAILY_REWARDS = [ + 'CurrencyA' => [ + 'min' => 300, + 'max' => 500, + ], + 'CurrencyB' => [ + 'min' => 600, + 'max' => 1000, + ], + 'CurrencyC' => [ + 'min' => 500, + 'max' => 900, + ], + ]; + + const WEEKLY_REWARDS = [ + 'CurrencyA' => [ + 'min' => 2000, + 'max' => 3000, + ], + 'CurrencyB' => [ + 'min' => 4000, + 'max' => 6000, + ], + 'CurrencyC' => [ + 'min' => 3000, + 'max' => 5000, + ], + ]; + + /** + * Makes a new TimedChallenge Instance that's not yet saved to the Database. + * + * @param Carbon $startTime + * @param Faction $faction + * @param ChallengeType $type + * @return void + * @throws TimedChallengeFactoryException + */ + public static function makeChallenge( + Carbon $startTime, + Faction $faction, + ChallengeType $type, + ): TimedChallenge + { + [$blueprintPath, $completionValue] = static::pickChallenge($faction, $type); + + $challenge = new TimedChallenge(); + $challenge->type = $type; + $challenge->faction = $faction; + $challenge->blueprint_path = $blueprintPath; + $challenge->completion_value = $completionValue; + + $challenge->start_time = $startTime; + + if($type === ChallengeType::Daily) + $challenge->end_time = $startTime->copy()->addDay(); + else + $challenge->end_time = $startTime->copy()->addWeek(); + + $challenge->rewards = [static::pickReward($type)]; + + return $challenge; + } + + protected static function pickChallenge( + Faction $faction, + ChallengeType $type, + ): array { + $challengeArray = match ($faction) { + Faction::Hunter => $type === ChallengeType::Daily ? static::AVAILABLE_DAILY_HUNTER : static::AVAILABLE_WEEKLY_HUNTER, + Faction::Runner => $type === ChallengeType::Daily ? static::AVAILABLE_DAILY_RUNNER : static::AVAILABLE_WEEKLY_RUNNER, + default => null, + }; + + if($challengeArray === null) + throw new TimedChallengeFactoryException('Unallowed Faction ('. $faction->value .'), could not select a challenge.'); + + $blueprintPath = array_rand($challengeArray); + $completionValue = $challengeArray[$blueprintPath]; + + return [$blueprintPath, $completionValue]; + } + + /** + * @param ChallengeType $type + * @return Reward + * @throws TimedChallengeFactoryException + */ + protected static function pickReward( + ChallengeType $type, + ): Reward { + $rewardsArray = match ($type) { + ChallengeType::Daily => static::DAILY_REWARDS, + ChallengeType::Weekly => static::WEEKLY_REWARDS, + default => null, + }; + + if($rewardsArray === null) + throw new TimedChallengeFactoryException('Unallowed Challenge Type (' . $type->value . '), could not select a reward.'); + + $pickedReward = array_rand($rewardsArray); + + return new Reward( + RewardType::Currency, + round(rand($rewardsArray[$pickedReward]['min'], $rewardsArray[$pickedReward]['max']) / 10) * 10, + $pickedReward, + ); + } +} diff --git a/dist/app/Console/Commands/GenerateTimedChallenges.php b/dist/app/Console/Commands/GenerateTimedChallenges.php new file mode 100644 index 0000000..fdec404 --- /dev/null +++ b/dist/app/Console/Commands/GenerateTimedChallenges.php @@ -0,0 +1,94 @@ +setTime(static::INTERVAL_HOUR, static::INTERVAL_MINUTE); + + // Put the current Daily a day in the past when the current time the job runs is still before the new Daily + if(Carbon::now()->isBefore($dailyToday)) + $dailyToday->subDay(); + + $dailyTomorrow = $dailyToday->copy()->addDay(); + $currentWeekly = Carbon::parse('last '.static::WEEKLY_INTERVAL_DAY); + $currentWeekly->setTime(static::INTERVAL_HOUR, static::INTERVAL_MINUTE); + $nextWeekly = $currentWeekly->copy()->addWeek(); + + $dailys = [$dailyToday, $dailyTomorrow]; + $weeklys = [$currentWeekly, $nextWeekly]; + + $log = Log::channel('challengeCreation'); + + foreach ([Faction::Hunter, Faction::Runner] as $faction) { + foreach ($dailys as $time) { + $dailyExists = TimedChallenge::whereDate('start_time', $time) + ->where('type', ChallengeType::Daily) + ->where('faction', $faction) + ->exists(); + + if(!$dailyExists) { + try { + $newDaily = TimedChallengeFactory::makeChallenge($time, $faction, ChallengeType::Daily); + $newDaily->save(); + } catch (TimedChallengeFactoryException $e) { + $log->error($e->getMessage()); + } + } + } + + foreach ($weeklys as $time) { + $weeklyExists = TimedChallenge::whereDate('start_time', $time) + ->where('type', ChallengeType::Weekly) + ->where('faction', $faction) + ->exists(); + + if(!$weeklyExists) { + try { + $newDaily = TimedChallengeFactory::makeChallenge($time, $faction, ChallengeType::Weekly); + $newDaily->save(); + } catch (TimedChallengeFactoryException $e) { + $log->error($e->getMessage()); + } + } + } + } + } +} diff --git a/dist/app/Console/Kernel.php b/dist/app/Console/Kernel.php index e99c6e6..84f963e 100644 --- a/dist/app/Console/Kernel.php +++ b/dist/app/Console/Kernel.php @@ -15,6 +15,7 @@ protected function schedule(Schedule $schedule): void $schedule->command('model:prune')->daily(); $schedule->command('matchmaking:process')->everyFiveSeconds(); $schedule->command('matchmaking:cleanup')->everyFifteenSeconds(); + $schedule->command('app:generate-timed-challenges')->daily(); } /** diff --git a/dist/app/Enums/Game/ChallengeType.php b/dist/app/Enums/Game/ChallengeType.php new file mode 100644 index 0000000..1757517 --- /dev/null +++ b/dist/app/Enums/Game/ChallengeType.php @@ -0,0 +1,11 @@ + []]); + $user = $request->user; + $timedChallenges = TimedChallenge::where('start_time', '<', Carbon::now()) + ->where('end_time', '>', Carbon::now()) + ->where('type', $request->type) + ->get(); + + $challenges = []; + $timedChallenges->each(function (TimedChallenge $challenge) use (&$challenges, $user) { + $challenges[] = new GetChallengesEntry( + $challenge->id, + $challenge->start_time, + $challenge->end_time, + $challenge->type, + $challenge->completion_value, + $challenge->faction, + $challenge->blueprint_path, + $challenge->rewards, + $challenge->hasPlayerClaimed($user->playerData()->id), + ); + }); + + return Response::json(['challenges' => $challenges]); } public function getProgressionBatch(GetChallengeProgressionBatchRequest $request) @@ -36,12 +68,23 @@ public function getProgressionBatch(GetChallengeProgressionBatchRequest $request $user = User::findOrFail($request->userId); $playerData = $user->playerData(); + $timedChallengeIds = []; + + foreach ($request->challengeIds as $index => $id) { + if(Str::startsWith($id, GetChallengesEntry::ID_PREFIX)) { + $timedChallengeIds[] = Str::remove(GetChallengesEntry::ID_PREFIX, $id); + unset($request->challengeIds[$index]); + } + } + $challengeIdsToCheck = UuidHelper::convertFromHexToUuidCollecton($request->challengeIds, true); /** @var Challenge[]|Collection $challengesToCheck */ $challengesToCheck = Challenge::findMany($challengeIdsToCheck); /** @var PickedChallenge[]|Collection $pickedChallengesToCheck */ $pickedChallengesToCheck = PickedChallenge::findMany($challengeIdsToCheck); + /** @var TimedChallenge[]|Collection $timedChallengesToCheck */ + $timedChallengesToCheck = TimedChallenge::findMany($timedChallengeIds); $response = new ChallengeProgressionBatchResponse(); @@ -57,6 +100,20 @@ public function getProgressionBatch(GetChallengeProgressionBatchRequest $request $response->progressionBatch[] = $newEntry; } + foreach ($timedChallengesToCheck as $challenge) { + $progress = $challenge->getProgressForPlayer($playerData->id); + + $entry = new ChallengeProgressionEntry( + GetChallengesEntry::ID_PREFIX . $challenge->id, + $challenge->progress >= $challenge->completion_value, + $progress, + ); + + $entry->rewardsClaimed = $challenge->rewards; + + $response->progressionBatch[] = $entry; + } + foreach ($pickedChallengesToCheck as $challenge) { $response->progressionBatch[] = new ChallengeProgressionEntry( Uuid::fromString($challenge->id)->getHex()->toString(), @@ -65,6 +122,8 @@ public function getProgressionBatch(GetChallengeProgressionBatchRequest $request ); } + foreach ($challengesToCheck as $challenge) {} + return json_encode($response); } @@ -83,7 +142,11 @@ public function executeChallengeProgressionBatch(ExecuteChallengeProgressionBatc $processedChallenges = []; foreach ($request->operations as $operation) { - $challengeId = Uuid::fromString($operation['challengeId'])->toString(); + $challengeId = $operation['challengeId']; + + if(!Str::startsWith($challengeId, GetChallengesEntry::ID_PREFIX)) { + $challengeId = Uuid::fromString($operation['challengeId'])->toString(); + } // Skip if we already processed this challenge because the completed ones are always first in the array. if(in_array($challengeId, $processedChallenges)) @@ -99,8 +162,13 @@ public function executeChallengeProgressionBatch(ExecuteChallengeProgressionBatc protected function addProgressToChallenge(string $challengeId, int $newProgress, User $user): void { - /** @var Challenge|null $foundChallenge */ - $foundChallenge = $user->playerData()->challenges()->find($challengeId); + if(Str::startsWith($challengeId, GetChallengesEntry::ID_PREFIX)) { + $foundChallenge = TimedChallenge::find(Str::remove(GetChallengesEntry::ID_PREFIX, $challengeId)); + } + else { + /** @var Challenge|null $foundChallenge */ + $foundChallenge = $user->playerData()->challenges()->find($challengeId); + } if($foundChallenge !== null) { $foundChallenge->playerData()->updateExistingPivot($user->playerData()->id, [ @@ -119,13 +187,38 @@ protected function addProgressToChallenge(string $challengeId, int $newProgress, protected function setChallengeAsCompleted(string $challengeId, User $user) { - /** @var Challenge|null $foundChallenge */ - $foundChallenge = $user->playerData()->challenges()->find($challengeId); + $isTimed = false; + // If the Challenge id starts with the Prefix, handle it as a timed challenge. + if(Str::startsWith($challengeId, GetChallengesEntry::ID_PREFIX)) { + $foundChallenge = TimedChallenge::find(Str::remove(GetChallengesEntry::ID_PREFIX, $challengeId)); + $isTimed = true; + } + else { + /** @var Challenge|null $foundChallenge */ + $foundChallenge = $user->playerData()->challenges()->find($challengeId); + } if($foundChallenge !== null) { - $foundChallenge->playerData()->updateExistingPivot($user->playerData()->id, [ + $pivotToEdit = [ 'progress' => $foundChallenge->completion_value, - ]); + ]; + + if($isTimed) { + $playerData = $user->playerData(); + $hasClaimed = $foundChallenge->hasPlayerClaimed($playerData->id); + + if(!$hasClaimed) { + $rewardsToAdd = $foundChallenge->getRewards(); + + foreach($rewardsToAdd as $reward) { + $this->addReward($reward, $playerData); + } + $playerData->save(); + $pivotToEdit['claimed'] = true; + } + } + + $foundChallenge->playerData()->updateExistingPivot($user->playerData()->id, $pivotToEdit); $foundChallenge->save(); return; } @@ -137,4 +230,33 @@ protected function setChallengeAsCompleted(string $challengeId, User $user) $foundChallenge->progress = $foundChallenge->completion_value; $foundChallenge->save(); } + + protected function addReward(Reward $reward, PlayerData &$playerData): void + { + if ($reward->type === RewardType::Currency) { + switch ($reward->id) { + case 'CurrencyA': + $playerData->currency_a += $reward->amount; + break; + case 'CurrencyB': + $playerData->currency_b += $reward->amount; + break; + case 'CurrencyC': + $playerData->currency_c += $reward->amount; + break; + default: + return; + } + } + else if ($reward->type === RewardType::Inventory) { + $itemUuid = Uuid::fromString($reward->id)->toString(); + + try { + // Since we can only have one occurrence of an item in the inventory, and an exception gets thrown when we try to add the same item twice + $playerData->inventory()->attach($itemUuid); + } catch (UniqueConstraintViolationException $e) { + + } + } + } } diff --git a/dist/app/Http/Requests/Api/Player/GetChallengesRequest.php b/dist/app/Http/Requests/Api/Player/GetChallengesRequest.php new file mode 100644 index 0000000..ff4658f --- /dev/null +++ b/dist/app/Http/Requests/Api/Player/GetChallengesRequest.php @@ -0,0 +1,42 @@ +|string> + */ + public function rules(): array + { + return [ + 'data.userId' => 'required|exists:users,id', + 'data.challengeType' => ['required', Rule::enum(ChallengeType::class)], + ]; + } + + public function passedValidation(): void + { + $this->user = User::find($this->input('data.userId')); + $this->type = ChallengeType::tryFrom($this->input('data.challengeType')); + } +} diff --git a/dist/app/Http/Responses/Api/Player/Challenges/GetChallengesEntry.php b/dist/app/Http/Responses/Api/Player/Challenges/GetChallengesEntry.php new file mode 100644 index 0000000..99301f8 --- /dev/null +++ b/dist/app/Http/Responses/Api/Player/Challenges/GetChallengesEntry.php @@ -0,0 +1,56 @@ +id; + } + + public function jsonSerialize(): mixed + { + $json = [ + 'lifetime' => + [ + 'creationTime' => $this->startTime->toIso8601ZuluString(), + 'expirationTime' => $this->endTime->toIso8601ZuluString(), + ], + 'challengeType' => $this->type, + 'challengeId' => $this->getChallengeId(), + 'challengeCompletionValue' => $this->completionValue, + 'faction' => $this->faction, + 'challengeBlueprint' => $this->challengeBlueprint, + ]; + + $rewards = []; + foreach ($this->rewards as &$reward) { + $reward['claimed'] = $this->claimed; + $reward['weight'] = 100; + $rewards[] = $reward; + } + $json['rewards'] = $rewards; + + return $json; + } +} \ No newline at end of file diff --git a/dist/app/Models/Game/TimedChallenge.php b/dist/app/Models/Game/TimedChallenge.php new file mode 100644 index 0000000..d6c1782 --- /dev/null +++ b/dist/app/Models/Game/TimedChallenge.php @@ -0,0 +1,78 @@ + ChallengeType::class, + 'faction' => Faction::class, + 'start_time' => 'datetime', + 'end_time' => 'datetime', + 'rewards' => 'array', + ]; + + public function getProgressForPlayer(int $playerDataId): int + { + $foundProgress = $this->playerData()->where('id', '=', $playerDataId)->first(); + + if ($foundProgress !== null) + return $foundProgress->pivot->progress; + + // We create challange relation and just return 0 since you cannot have progress for the challenge we just linked. + $this->playerData()->attach($playerDataId); + return 0; + } + + public function hasPlayerClaimed(int $playerDataId): bool { + $foundPlayerData = $this->playerData()->where('id', '=', $playerDataId)->first(); + + if ($foundPlayerData !== null) + return $foundPlayerData->pivot->claimed; + + return false; + } + + /** + * @return Reward[] + */ + public function getRewards(): array { + if ($this->rewards === null) + return []; + + $result = []; + foreach ($this->rewards as $reward) { + $result[] = new Reward( + RewardType::tryFrom($reward['type']), + $reward['amount'], + $reward['id'], + ); + } + + return $result; + } + + public function playerData(): BelongsToMany + { + return $this->belongsToMany(PlayerData::class)->withPivot(['progress', 'claimed']); + } + + public static function currentChallenges(): Eloquent|Builder { + return static::where('start_time', '>', Carbon::now()) + ->where('end_time', '<', Carbon::now()); + } +} diff --git a/dist/app/Models/User/PlayerData.php b/dist/app/Models/User/PlayerData.php index fc5af26..2afa6b9 100644 --- a/dist/app/Models/User/PlayerData.php +++ b/dist/app/Models/User/PlayerData.php @@ -12,6 +12,7 @@ use App\Models\Game\Challenge; use App\Models\Game\CharacterData; use App\Models\Game\QuitterState; +use App\Models\Game\TimedChallenge; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -143,6 +144,10 @@ public function challenges(): BelongsToMany return $this->belongsToMany(Challenge::class)->withPivot(['progress']); } + public function timedChallenges(): BelongsToMany { + return $this->belongsToMany(TimedChallenge::class)->withPivot(['progress', 'claimed']); + } + public function quitterState(): HasOne { return $this->hasOne(QuitterState::class); diff --git a/dist/config/logging.php b/dist/config/logging.php index b64fcdc..0ecbd3f 100644 --- a/dist/config/logging.php +++ b/dist/config/logging.php @@ -135,6 +135,13 @@ 'replace_placeholders' => true, ], + 'challengeCreation' => [ + 'driver' => 'single', + 'path' => storage_path('logs/challengeCreation.log'), + 'level' => 'info', + 'replace_placeholders' => true, + ], + 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), diff --git a/dist/database/migrations/2024_12_03_201112_create_timed_challenges_table.php b/dist/database/migrations/2024_12_03_201112_create_timed_challenges_table.php new file mode 100644 index 0000000..8bd2fef --- /dev/null +++ b/dist/database/migrations/2024_12_03_201112_create_timed_challenges_table.php @@ -0,0 +1,36 @@ +id(); + $table->enum('type', array_column(ChallengeType::cases(), 'value')); + $table->string('blueprint_path'); + $table->enum('faction', array_column(Faction::cases(), 'value')); + $table->integer('completion_value'); + $table->datetime('start_time'); + $table->datetime('end_time'); + $table->json('rewards')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('timed_challenges'); + } +}; diff --git a/dist/database/migrations/2024_12_03_203904_create_player_data_timed_chalenge_table.php b/dist/database/migrations/2024_12_03_203904_create_player_data_timed_chalenge_table.php new file mode 100644 index 0000000..792f6a1 --- /dev/null +++ b/dist/database/migrations/2024_12_03_203904_create_player_data_timed_chalenge_table.php @@ -0,0 +1,33 @@ +foreignId('timed_challenge_id')->constrained()->cascadeOnDelete()->cascadeOnUpdate(); + $table->foreignId('player_data_id')->constrained('player_data')->cascadeOnDelete()->cascadeOnUpdate(); + $table->unsignedInteger('progress')->default(0); + $table->boolean('claimed')->default(false); + + $table->timestamps(); + + $table->unique(['timed_challenge_id', 'player_data_id'], 'unique_primary'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('player_data_timed_challenge'); + } +};