From 14aed7bf6e85663a4022357c6dcf769c895e3825 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 26 Apr 2026 20:00:11 +1000 Subject: [PATCH] Added achievement data --- app/Models/Achievement.php | 2 + app/Models/Airline.php | 7 +- app/Models/Alliance.php | 25 +++ app/Models/User.php | 5 +- app/Models/UserFlight.php | 8 + app/Observers/AirlineObserver.php | 37 ++++ app/Observers/FlightObserver.php | 35 ++++ app/Providers/AppServiceProvider.php | 6 + .../Achievements/AchievementService.php | 161 ++++++++++++++++ .../Checkers/AchievementCheckerInterface.php | 15 ++ .../Achievements/Checkers/AircraftChecker.php | 146 ++++++++++++++ .../Checkers/AirlinesAndAlliancesChecker.php | 29 +++ .../Achievements/Checkers/BaseChecker.php | 70 +++++++ .../CountriesAndContinentsChecker.php | 97 ++++++++++ .../Checkers/FunChallengesChecker.php | 37 ++++ .../Checkers/GeneralFlyingChecker.php | 78 ++++++++ app/Traits/HasAchievements.php | 18 ++ ..._04_26_030253_build_achievement_system.php | 180 +++++++++++++++++- 18 files changed, 950 insertions(+), 6 deletions(-) create mode 100644 app/Models/Alliance.php create mode 100644 app/Observers/AirlineObserver.php create mode 100644 app/Observers/FlightObserver.php create mode 100644 app/Services/Achievements/AchievementService.php create mode 100644 app/Services/Achievements/Checkers/AchievementCheckerInterface.php create mode 100644 app/Services/Achievements/Checkers/AircraftChecker.php create mode 100644 app/Services/Achievements/Checkers/AirlinesAndAlliancesChecker.php create mode 100644 app/Services/Achievements/Checkers/BaseChecker.php create mode 100644 app/Services/Achievements/Checkers/CountriesAndContinentsChecker.php create mode 100644 app/Services/Achievements/Checkers/FunChallengesChecker.php create mode 100644 app/Services/Achievements/Checkers/GeneralFlyingChecker.php create mode 100644 app/Traits/HasAchievements.php diff --git a/app/Models/Achievement.php b/app/Models/Achievement.php index fa6b645..685a649 100644 --- a/app/Models/Achievement.php +++ b/app/Models/Achievement.php @@ -37,10 +37,12 @@ class Achievement extends Model 'difficulty_description', 'achievement_category_id', 'achievement_difficulty_id', + 'threshold', ]; protected $casts = [ 'progressive' => 'boolean', + 'threshold' => 'integer', ]; // --------------------------------------------------------------- diff --git a/app/Models/Airline.php b/app/Models/Airline.php index 84ba944..66a5a55 100644 --- a/app/Models/Airline.php +++ b/app/Models/Airline.php @@ -17,6 +17,7 @@ class Airline extends Model 'name', 'internal_name', 'country_id', + 'alliance_id', 'active', 'logo', ]; @@ -25,7 +26,6 @@ class Airline extends Model 'active' => 'boolean', ]; - public $timestamps = false; protected $appends = [ 'display_name', @@ -50,6 +50,11 @@ class Airline extends Model ); } + public function alliance(): BelongsTo + { + return $this->belongsTo(Alliance::class); + } + public function country(): BelongsTo { return $this->belongsTo(Country::class); diff --git a/app/Models/Alliance.php b/app/Models/Alliance.php new file mode 100644 index 0000000..b3416e8 --- /dev/null +++ b/app/Models/Alliance.php @@ -0,0 +1,25 @@ + 'string', + ]; + + public function airlines(): HasMany + { + return $this->hasMany(Airline::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f573292..8822cc7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,12 +13,15 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use App\Traits\HasAchievements; + #[Fillable(['name', 'email', 'password'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable { + /** @use HasFactory */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasAchievements; /** * Get the attributes that should be cast. diff --git a/app/Models/UserFlight.php b/app/Models/UserFlight.php index d07f94d..18afadf 100644 --- a/app/Models/UserFlight.php +++ b/app/Models/UserFlight.php @@ -148,6 +148,14 @@ class UserFlight extends Model ); } + public function isDomestic() : bool{ + return $this->departureAirport->region->country_id == $this->arrivalAirport->region->country_id; + } + + public function isInternational() : bool{ + return !$this->isDomestic(); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Observers/AirlineObserver.php b/app/Observers/AirlineObserver.php new file mode 100644 index 0000000..53147df --- /dev/null +++ b/app/Observers/AirlineObserver.php @@ -0,0 +1,37 @@ +alliance_id !== null) { + $this->syncAllianceThresholds(); + } + } + + public function updated(Airline $airline): void + { + if ($airline->wasChanged('alliance_id')) { + $this->syncAllianceThresholds(); + } + } + + public function deleted(Airline $airline): void + { + $this->syncAllianceThresholds(); + } + + private function syncAllianceThresholds(): void + { + Alliance::withCount('airlines')->each(function (Alliance $alliance) { + Achievement::where('internal_name', "airlines_alliances.all_{$alliance->internal_name}") + ->update(['threshold' => $alliance->airlines_count]); + }); + } +} diff --git a/app/Observers/FlightObserver.php b/app/Observers/FlightObserver.php new file mode 100644 index 0000000..0ac6b5e --- /dev/null +++ b/app/Observers/FlightObserver.php @@ -0,0 +1,35 @@ +user->calculateAchievements(); + } + + /** + * Recalculate after a flight is updated. + * Cabin class, flight type, airline, etc. may have changed, + * which could unlock or revoke achievements. + */ + public function updated(UserFlight $flight): void + { + $flight->user->calculateAchievements(); + } + + /** + * Recalculate after a flight is deleted. + * Previously earned achievements may no longer be valid. + */ + public function deleted(UserFlight $flight): void + { + $flight->user->calculateAchievements(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 96e9f6c..2e8ffec 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,10 @@ namespace App\Providers; +use App\Models\Airline; +use App\Models\UserFlight; +use App\Observers\AirlineObserver; +use App\Observers\FlightObserver; use Illuminate\Support\Facades\Vite; use Illuminate\Support\ServiceProvider; @@ -21,5 +25,7 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { Vite::prefetch(concurrency: 3); + UserFlight::observe(FlightObserver::class); + Airline::observe(AirlineObserver::class); } } diff --git a/app/Services/Achievements/AchievementService.php b/app/Services/Achievements/AchievementService.php new file mode 100644 index 0000000..1eeb7ec --- /dev/null +++ b/app/Services/Achievements/AchievementService.php @@ -0,0 +1,161 @@ +achievementCache = collect(); + } + + // --------------------------------------------------------------- + // Orchestration + // --------------------------------------------------------------- + + /** + * @var Collection $flights + */ + private Collection $flights; + + public function calculate(User $user): void + { + $this->user = $user; + $this->achievementCache = Achievement::all()->keyBy('internal_name'); + + // Load once with every relationship any checker could need + $this->flights = $user->flights()->with([ + 'airline.alliance', + 'aircraft', + 'flightClass', + 'departureAirport.region.continent', + 'arrivalAirport.region.continent', + ])->get(); + + foreach ($this->checkers as $checkerClass) { + $checker = new $checkerClass($this); + $checker->check($user); + } + } + + /** + * @return Collection + */ + public function getFlights(): Collection + { + return $this->flights; + } + + + // --------------------------------------------------------------- + // Award / revoke + // --------------------------------------------------------------- + + /** + * Upsert a user_achievement row and fire a notification if this + * is a newly unlocked achievement. + */ + public function award(Achievement $achievement, User $user, ?int $progress = null): void + { + $existing = UserAchievement::where('user_id', $user->id) + ->where('achievement_id', $achievement->id) + ->first(); + + $alreadyUnlocked = $existing !== null; + + UserAchievement::updateOrCreate( + ['user_id' => $user->id, 'achievement_id' => $achievement->id], + ['progress' => $progress], + ); + + if (! $alreadyUnlocked) { + $this->createAchievementNotification($user, $achievement); + } + } + + /** + * Update progress on a progressive achievement without marking it + * as unlocked (i.e. progress exists but threshold not yet met). + * Creates the row if it doesn't exist yet. + */ + public function updateProgress(Achievement $achievement, User $user, int $progress): void + { + UserAchievement::updateOrCreate( + ['user_id' => $user->id, 'achievement_id' => $achievement->id], + ['progress' => $progress], + ); + } + + /** + * Remove a user_achievement row and its associated notification + * if the achievement is no longer valid for this user. + */ + public function revoke(Achievement $achievement, User $user): void + { + UserAchievement::where('user_id', $user->id) + ->where('achievement_id', $achievement->id) + ->delete(); + + Notification::where('user_id', $user->id) + ->where('achievement_id', $achievement->id) + ->delete(); + } + + // --------------------------------------------------------------- + // Helpers for checkers + // --------------------------------------------------------------- + + public function resolveAchievement(string $internalName): ?Achievement + { + $achievement = $this->achievementCache->get($internalName); + return $achievement instanceof Achievement ? $achievement : null; + } + + public function currentUser(): User + { + return $this->user; + } + + // --------------------------------------------------------------- + // Notifications + // --------------------------------------------------------------- + + private function createAchievementNotification(User $user, Achievement $achievement): void + { + Notification::create([ + 'user_id' => $user->id, + 'title' => 'Achievement Unlocked!', + 'body' => "You've earned the \"{$achievement->name}\" achievement — {$achievement->short_description}", + 'is_achievement' => true, + 'achievement_id' => $achievement->id, + 'expires_at' => null, + ]); + } +} diff --git a/app/Services/Achievements/Checkers/AchievementCheckerInterface.php b/app/Services/Achievements/Checkers/AchievementCheckerInterface.php new file mode 100644 index 0000000..567d27b --- /dev/null +++ b/app/Services/Achievements/Checkers/AchievementCheckerInterface.php @@ -0,0 +1,15 @@ +service->award() or $this->service->revoke() + * — never touch user_achievements directly. + */ + public function check(): void; +} diff --git a/app/Services/Achievements/Checkers/AircraftChecker.php b/app/Services/Achievements/Checkers/AircraftChecker.php new file mode 100644 index 0000000..3ac243f --- /dev/null +++ b/app/Services/Achievements/Checkers/AircraftChecker.php @@ -0,0 +1,146 @@ + ['B701', 'B703', 'B720'], + '717' => ['B712', 'B717'], + '727' => ['B721', 'B722', 'B727'], + '737' => ['B731', 'B732', 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', 'B37M', 'B38M', 'B39M'], + '747' => ['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S'], + '757' => ['B752', 'B753', 'B757'], + '767' => ['B762', 'B763', 'B764', 'B767'], + '777' => ['B772', 'B773', 'B77L', 'B77W', 'B778', 'B779'], + '787' => ['B788', 'B789', 'B78X'], + ]; + + private const array AIRBUS_FAMILIES = [ + 'A300' => ['A30B', 'A300', 'A306'], + 'A310' => ['A310', 'A312', 'A313'], + 'A318' => ['A318'], + 'A319' => ['A319', 'A31X'], + 'A320' => ['A320', 'A20N'], + 'A321' => ['A321', 'A21N'], + 'A330' => ['A330', 'A332', 'A333', 'A338', 'A339'], + 'A340' => ['A340', 'A342', 'A343', 'A345', 'A346'], + 'A350' => ['A350', 'A358', 'A359', 'A35K'], + 'A380' => ['A380', 'A388'], + ]; + + private const array DOUBLE_DECKER_DESIGNATORS = [ + // A380 + 'A380', 'A388', + // 747 variants + 'B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S', + ]; + + public function check(): void + { + /** + * @var $flights Collection + */ + $flights = $this->flights(); + + + $flightsWithAircraft = $flights->filter(fn($f) => $f->aircraft !== null); + + + $this->awardIf( + $flightsWithAircraft->contains( + fn(UserFlight $f) => in_array($f->aircraft->aircraft_description, ['LandPlane', 'SeaPlane']) + ), + 'aircraft.fly_on_a_plane' + ); + + $this->awardIf( + $flightsWithAircraft->contains( + fn(UserFlight $f) => $f->aircraft->aircraft_description === 'Helicopter' + ), + 'aircraft.fly_on_a_helicopter' + ); + + $this->awardIf( + $flightsWithAircraft->contains( + fn(UserFlight $f) => $f->aircraft->engine_type === 'Jet' + ), + 'aircraft.fly_on_a_jet' + ); + + $this->awardIf( + $flightsWithAircraft->contains( + fn(UserFlight $f) => $f->aircraft->engine_type === 'Turboprop/Turboshaft' || $f->aircraft->engine_type === 'Piston' + ), + 'aircraft.fly_on_a_prop' + ); + + // --- Engine count achievements --- + + $this->awardIf( + $flightsWithAircraft->contains(fn(UserFlight $f) => $f->aircraft->engine_count === 1 && $f->aircraft->aircraft_description !== 'Helicopter'), + 'aircraft.single_engine' + ); + + $this->awardIf( + $flightsWithAircraft->contains(fn(UserFlight $f) => $f->aircraft->engine_count === 2 && $f->aircraft->aircraft_description !== 'Helicopter'), + 'aircraft.twin_engine' + ); + + $this->awardIf( + $flightsWithAircraft->contains(fn(UserFlight $f) => $f->aircraft->engine_count === 3 && $f->aircraft->aircraft_description !== 'Helicopter'), + 'aircraft.tri_engine' + ); + + $this->awardIf( + $flightsWithAircraft->contains(fn(UserFlight $f) => $f->aircraft->engine_count === 4 && $f->aircraft->aircraft_description !== 'Helicopter'), + 'aircraft.quad_engine' + ); + + // --- Double decker --- + + $this->awardIf( + $flightsWithAircraft->contains( + fn(UserFlight $f) => in_array($f->aircraft->designator, self::DOUBLE_DECKER_DESIGNATORS) + ), + 'aircraft.double_decker' + ); + + // --- Smaller manufacturer (scheduled service only) --- + + $this->awardIf( + $flightsWithAircraft->contains( + fn(UserFlight $f) => $f->airline && $f->flight_number && !in_array(strtolower($f->aircraft->manufacturer_code), ['boeing', 'airbus']) + ), + 'aircraft.smaller_manufacturer' + ); + + // --- Boeing 7x7 families --- + + $flownBoeingFamilies = collect(self::BOEING_FAMILIES) + ->filter(fn($designators) => + $flightsWithAircraft->contains( + fn(UserFlight $f) => in_array($f->aircraft->designator, $designators) + ) + ) + ->count(); + + $this->awardProgress($flownBoeingFamilies, 'aircraft.all_boeing_7x7'); + + // --- Airbus A3xx families --- + + $flownAirbusFamilie = collect(self::AIRBUS_FAMILIES) + ->filter(fn($designators) => + $flightsWithAircraft->contains( + fn(UserFlight $f) => in_array($f->aircraft->designator, $designators) + ) + ) + ->count(); + + $this->awardProgress($flownAirbusFamilie, 'aircraft.all_airbus_a3xx'); + } +} diff --git a/app/Services/Achievements/Checkers/AirlinesAndAlliancesChecker.php b/app/Services/Achievements/Checkers/AirlinesAndAlliancesChecker.php new file mode 100644 index 0000000..a45c95f --- /dev/null +++ b/app/Services/Achievements/Checkers/AirlinesAndAlliancesChecker.php @@ -0,0 +1,29 @@ +flights(); + + $alliances = Alliance::withCount('airlines')->pluck('id', 'internal_name'); + + $flownAllianceAirlines = $flights + ->filter(fn(UserFlight $f) => $f->airline?->alliance !== null) + ->groupBy(fn(UserFlight $f) => $f->airline->alliance->internal_name) + ->map(fn($group) => $group->pluck('airline.internal_name')->unique()->count()); + + $check = fn(string $alliance): int => $flownAllianceAirlines->get($alliance, 0); + + $this->awardProgress($check('skyteam'), 'airlines_alliances.all_skyteam'); + $this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld'); + $this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance'); + $this->awardProgress($check('vanilla_alliance'), 'airlines_alliances.all_vanilla_alliance'); + } +} diff --git a/app/Services/Achievements/Checkers/BaseChecker.php b/app/Services/Achievements/Checkers/BaseChecker.php new file mode 100644 index 0000000..131cdd7 --- /dev/null +++ b/app/Services/Achievements/Checkers/BaseChecker.php @@ -0,0 +1,70 @@ +service->getFlights(); + } + + /** + * Resolve an achievement ID from its internal name. + * Results are cached on the service so repeated lookups are free. + */ + protected function achievement(string $internalName): ?Achievement + { + return $this->service->resolveAchievement($internalName); + } + + /** + * Award a boolean (non-progressive) achievement if the condition is met, + * or revoke it if the condition is no longer met. + */ + protected function awardIf(bool $condition, string $internalName): void + { + $achievement = $this->achievement($internalName); + + if (! $achievement) { + return; + } + + if ($condition) { + $this->service->award($achievement, $this->currentUser()); + } else { + $this->service->revoke($achievement, $this->currentUser()); + } + } + + /** + * Award a progressive achievement, updating progress and + * unlocking/revoking based on whether progress meets the threshold. + */ + protected function awardProgress(int $progress, string $internalName): void + { + $achievement = $this->achievement($internalName); + if (! $achievement) return; + + if ($progress >= $achievement->threshold) { + $this->service->award($achievement, $this->currentUser(), $progress); + } else { + $this->service->updateProgress($achievement, $this->currentUser(), $progress); + } + } + + /** + * The user currently being evaluated. + * Set by AchievementService before calling check(). + */ + protected function currentUser(): \App\Models\User + { + return $this->service->currentUser(); + } +} diff --git a/app/Services/Achievements/Checkers/CountriesAndContinentsChecker.php b/app/Services/Achievements/Checkers/CountriesAndContinentsChecker.php new file mode 100644 index 0000000..812545a --- /dev/null +++ b/app/Services/Achievements/Checkers/CountriesAndContinentsChecker.php @@ -0,0 +1,97 @@ +flights(); + // Resolve internal_name → id once, used throughout + $continents = Continent::pluck('id', 'internal_name'); + + $arrivalContinentIds = $flights->pluck('arrivalAirport.region.continent_id')->unique(); + + $has = fn(string $name) => $arrivalContinentIds->contains($continents[$name]); + + // --- Simple "fly to X continent" achievements --- + + $this->awardIf( + $flights->contains(fn(UserFlight $f) => + $f->departureAirport->region->continent_id !== $f->arrivalAirport->region->continent_id + ), + 'countries_continents.intercontinental' + ); + + $this->awardIf($has('africa'), 'countries_continents.fly_to_africa'); + $this->awardIf($has('asia'), 'countries_continents.fly_to_asia'); + $this->awardIf($has('oceania'), 'countries_continents.fly_to_oceania'); + $this->awardIf($has('antarctica'), 'countries_continents.fly_to_antarctica'); + $this->awardIf($has('europe'), 'countries_continents.fly_to_europe'); + $this->awardIf($has('south_america'), 'countries_continents.fly_to_south_america'); + $this->awardIf($has('north_america'), 'countries_continents.fly_to_north_america'); + + // --- All inhabited continents --- + + $inhabitedIds = collect(self::INHABITED_CONTINENTS)->map(fn($name) => $continents[$name]); + + $visitedInhabited = $arrivalContinentIds + ->intersect($inhabitedIds) + ->unique() + ->count(); + + $this->awardProgress($visitedInhabited, 'countries_continents.all_inhabited_continents'); + + // --- All continents including Antarctica --- + + $this->awardProgress( + $arrivalContinentIds->unique()->count(), + 'countries_continents.all_continents' + ); + + // --- Continent route pairs --- + + $this->checkContinentPairs($flights); + } + + /** + * @param Collection $flights + * @return void + */ + private function checkContinentPairs(Collection $flights): void + { + $oneWayRoutes = collect(); + $directedRoutes = collect(); + + foreach ($flights as $flight) { + $dep = $flight->departureAirport->region->continent->internal_name; + $arr = $flight->arrivalAirport->region->continent->internal_name; + + // Directed route key e.g. "europe→asia" + $directedRoutes->push("{$dep}→{$arr}"); + + // Undirected — sort alphabetically so "europe→asia" and "asia→europe" are the same + $undirected = implode('→', collect([$dep, $arr])->sort()->values()->all()); + $oneWayRoutes->push($undirected); + } + + $this->awardProgress( + $oneWayRoutes->unique()->count(), + 'countries_continents.all_continent_pairs_one_way' + ); + + $this->awardProgress( + $directedRoutes->unique()->count(), + 'countries_continents.all_continent_pairs_both_ways' + ); + } +} diff --git a/app/Services/Achievements/Checkers/FunChallengesChecker.php b/app/Services/Achievements/Checkers/FunChallengesChecker.php new file mode 100644 index 0000000..b9fe288 --- /dev/null +++ b/app/Services/Achievements/Checkers/FunChallengesChecker.php @@ -0,0 +1,37 @@ +flights(); + + $airlineLetters = $flights + ->filter(fn($f) => $f->airline?->IATA_code !== null) + ->map(fn($f) => strtoupper($f->airline->IATA_code[0])) + ->filter(fn($letter) => ctype_alpha($letter)) + ->unique() + ->count(); + + $this->awardProgress($airlineLetters, 'fun_challenges.airline_alphabet'); + + // --- Visit the Alphabet --- + // Collect first letters from both departure and arrival airport IATA codes + + $airportLetters = $flights + ->flatMap(fn($f) => [ + $f->departureAirport?->iata_code, + $f->arrivalAirport?->iata_code, + ]) + ->filter() + ->map(fn($code) => strtoupper($code[0])) + ->unique() + ->count(); + + $this->awardProgress($airportLetters, 'fun_challenges.airport_alphabet'); + } +} diff --git a/app/Services/Achievements/Checkers/GeneralFlyingChecker.php b/app/Services/Achievements/Checkers/GeneralFlyingChecker.php new file mode 100644 index 0000000..d126a31 --- /dev/null +++ b/app/Services/Achievements/Checkers/GeneralFlyingChecker.php @@ -0,0 +1,78 @@ + + */ + $flights = $this->flights(); + $count = $flights->count(); + + // --- Boolean achievements --- + + $this->awardIf($count >= 1, 'general_flying.first_flight'); + + $this->awardIf( + $flights->contains(fn ($f) => $f->isDomestic()), + 'general_flying.domestic_flight' + ); + + $this->awardIf( + $flights->contains(fn ($f) => $f->isInternational()), + 'general_flying.international_flight' + ); + + $this->awardIf( + $flights->contains(fn ($f) => $f->flightClass->internal_name === 'business'), + 'general_flying.business_class' + ); + + $this->awardIf( + $flights->contains(fn ($f) => $f->flightClass->internal_name === 'first'), + 'general_flying.first_class' + ); + + $this->awardIf( + $flights->contains(fn ($f) => $f->flightClass->internal_name === 'premium_economy'), + 'general_flying.premium_economy' + ); + + $this->awardIf( + $flights->contains(fn ($f) => in_array($f->flightClass->internal_name, ['business', 'first'])), + 'general_flying.business_or_first' + ); + + $this->awardIf( + $flights->contains(fn ($f) => $f->flightClass->internal_name === 'private'), + 'general_flying.fly_private' + ); + + $this->awardIf( + $flights->contains(fn ($f) => $f->flightClass->internal_name === 'general_aviation'), + 'general_flying.general_aviation' + ); + + $this->awardIf( + $flights->filter(fn (UserFlight $f) => $f->isDomestic()) + ->map(fn (UserFlight $f) => $f->departureAirport->region->country_id) + ->unique() + ->count() >= 2, + 'general_flying.domestic_two_countries' + ); + + // --- Progressive achievements --- + + $this->awardProgress($count,'general_flying.10_flights'); + $this->awardProgress($count,'general_flying.100_flights'); + $this->awardProgress($count,'general_flying.500_flights'); + $this->awardProgress($count,'general_flying.1000_flights'); + } +} diff --git a/app/Traits/HasAchievements.php b/app/Traits/HasAchievements.php new file mode 100644 index 0000000..a48e956 --- /dev/null +++ b/app/Traits/HasAchievements.php @@ -0,0 +1,18 @@ +calculate($this); + } +} diff --git a/database/migrations/2026_04_26_030253_build_achievement_system.php b/database/migrations/2026_04_26_030253_build_achievement_system.php index dbc6b76..d1e3a7c 100644 --- a/database/migrations/2026_04_26_030253_build_achievement_system.php +++ b/database/migrations/2026_04_26_030253_build_achievement_system.php @@ -1,5 +1,7 @@ string('short_description')->after('internal_name'); $table->text('long_description')->after('short_description'); + // Progressive tracking $table->boolean('progressive')->default(false)->after('long_description'); // Difficulty flavour text specific to this achievement $table->text('difficulty_description')->nullable()->after('progressive'); + $table->unsignedInteger('threshold')->nullable()->after('progressive'); + // Foreign keys $table->foreignId('achievement_category_id') @@ -182,6 +187,117 @@ return new class extends Migration $this->seedAchievements(); + $this->createAlliances(); + } + + public function createAlliances(): void + { + Schema::create('alliances', function (Blueprint $table) { + $table->id(); + $table->string('internal_name')->unique(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::table('airlines', function (Blueprint $table) { + $table->foreignId('alliance_id') + ->nullable() + ->constrained('alliances') + ->nullOnDelete(); + }); + + DB::table('alliances')->insert([ + ['internal_name' => 'skyteam', 'name' => 'SkyTeam', 'created_at' => now(), 'updated_at' => now()], + ['internal_name' => 'oneworld', 'name' => 'Oneworld', 'created_at' => now(), 'updated_at' => now()], + ['internal_name' => 'star_alliance', 'name' => 'Star Alliance', 'created_at' => now(), 'updated_at' => now()], + ['internal_name' => 'vanilla_alliance','name' => 'Vanilla Alliance', 'created_at' => now(), 'updated_at' => now()], + ]); + + Airline::whereInternalName('xiamen-airlines')->update(['internal_name' => 'xiamen-air', 'name' => 'XiamenAir']); + + $skyteam = Alliance::where('internal_name', 'skyteam')->first(); + $skyteamMembers = [ + 'aerolineas-argentinas', + 'aeromexico', + 'air-europa', + 'air-france', + 'china-airlines', + 'china-eastern', + 'delta', + 'garuda-indonesia', + 'kenya-airways', + 'klm', + 'korean-air', + 'middle-east-airlines', + 'saudia', + 'sas', + 'tarom', + 'vietnam-airlines', + 'virgin-atlantic', + 'xiamen-air' + ]; + + $star = Alliance::where('internal_name', 'star_alliance')->first(); + $starMembers = [ + 'aegean-airlines', + 'air-canada', + 'air-china', + 'air-india', + 'air-new-zealand', + 'all-nippon-airways', + 'asiana', + 'austrian', + 'avianca', + 'brussels-airlines', + 'copa-airlines', + 'croatia-airlines', + 'egyptair', + 'ethiopian-airlines', + 'eva-air', + 'ita-airways', + 'lot-polish-airlines', + 'lufthansa', + 'shenzhen-airlines', + 'singapore-airlines', + 'swiss', + 'tap-portugal', + 'thai-airways-international', + 'turkish-airlines', + 'united-airlines', + ]; + + $oneworld = Alliance::where('internal_name', 'oneworld')->first(); + $oneworldMembers = [ + 'alaska-airlines', + 'american-airlines', + 'british-airways', + 'cathay-pacific', + 'fiji-airways', + 'finnair', + 'hawaiian-airlines', + 'iberia', + 'japan-airlines', + 'malaysia-airlines', + 'oman-air', + 'qantas', + 'qatar-airways', + 'royal-air-maroc', + 'royal-jordanian', + 'srilankan' + ]; + + $vanilla = Alliance::where('internal_name', 'vanilla_alliance')->first(); + $vanillaMembers = [ + 'air-austral', + 'air-madagascar', + 'air-seychelles', + 'air-mauritius' + ]; + + Airline::whereIn('internal_name', $skyteamMembers)->update(['alliance_id' => $skyteam->id]); + Airline::whereIn('internal_name', $starMembers)->update(['alliance_id' => $star->id]); + Airline::whereIn('internal_name', $oneworldMembers)->update(['alliance_id' => $oneworld->id]); + Airline::whereIn('internal_name', $vanillaMembers)->update(['alliance_id' => $vanilla->id]); } private function seedAchievements(): void @@ -203,7 +319,6 @@ return new class extends Migration $funChallenges = $categories['fun_challenges']; $icon = 'standard_achievement.png'; - $now = now(); $achievements = [ @@ -217,6 +332,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $easy, @@ -228,6 +344,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $easy, @@ -239,6 +356,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $easy, @@ -249,6 +367,7 @@ return new class extends Migration 'short_description' => 'Fly in Business Class.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, @@ -260,6 +379,7 @@ return new class extends Migration 'short_description' => 'Fly in First Class.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, @@ -272,6 +392,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $expensive, @@ -283,6 +404,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $moderate, @@ -294,6 +416,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $expensive, @@ -305,6 +428,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $moderate, @@ -316,6 +440,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $moderate, @@ -327,6 +452,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 10, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $easy, @@ -338,6 +464,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 100, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $moderate, @@ -349,6 +476,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 500, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $hard, @@ -360,6 +488,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 1000, 'difficulty_description' => null, 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $expensive, @@ -375,6 +504,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $countriesAndContinents, 'achievement_difficulty_id'=> $moderate, @@ -386,6 +516,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $countriesAndContinents, 'achievement_difficulty_id'=> $easy, @@ -397,6 +528,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $countriesAndContinents, 'achievement_difficulty_id'=> $easy, @@ -407,6 +539,7 @@ return new class extends Migration 'short_description' => 'Fly to Oceania.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => null, 'achievement_category_id' => $countriesAndContinents, @@ -418,6 +551,7 @@ return new class extends Migration 'short_description' => 'Fly to Antarctica.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => 'Very few commercial or charter services operate to Antarctica, making this extremely difficult to pull off.', 'achievement_category_id' => $countriesAndContinents, @@ -430,6 +564,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $countriesAndContinents, 'achievement_difficulty_id'=> $easy, @@ -441,6 +576,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $countriesAndContinents, 'achievement_difficulty_id'=> $easy, @@ -452,6 +588,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $countriesAndContinents, 'achievement_difficulty_id'=> $easy, @@ -467,6 +604,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => false, + 'threshold' => null, 'difficulty_description' => null, 'achievement_category_id' => $aircraft, 'achievement_difficulty_id'=> $easy, @@ -477,6 +615,7 @@ return new class extends Migration 'short_description' => 'Fly on a helicopter.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => null, 'achievement_category_id' => $aircraft, @@ -488,6 +627,7 @@ return new class extends Migration 'short_description' => 'Fly on a jet-powered aircraft.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => null, 'achievement_category_id' => $aircraft, @@ -499,6 +639,7 @@ return new class extends Migration 'short_description' => 'Fly on a propeller driven aircraft.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => null, 'achievement_category_id' => $aircraft, @@ -512,6 +653,7 @@ return new class extends Migration 'icon' => $icon, 'progressive' => true, 'difficulty_description' => 'Requires flying the 707, 717, 727, 737, 747, 757, 767, 777, and 787 — some of which are no longer in commercial service.', + 'threshold' => 9, 'achievement_category_id' => $aircraft, 'achievement_difficulty_id'=> $impossible, ], @@ -522,6 +664,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 10, 'difficulty_description' => 'Covers the A300, A310, A318–A321, A330, A340, A350, and A380 families. You are likely going to have to go to Iran to get this one.', 'achievement_category_id' => $aircraft, 'achievement_difficulty_id'=> $nearImposs, @@ -532,6 +675,7 @@ return new class extends Migration 'short_description' => 'Fly on a four-engine aircraft.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => 'Four-engine jets are becoming increasingly rare as twin-engine widebodies dominate long-haul routes.', 'achievement_category_id' => $aircraft, @@ -543,6 +687,7 @@ return new class extends Migration 'short_description' => 'Fly on a double-decker aircraft.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => 'Primarily the A380 and 747, which operate on a limited and shrinking number of routes.', 'achievement_category_id' => $aircraft, @@ -554,17 +699,31 @@ return new class extends Migration 'short_description' => 'Fly on a single-engine aircraft.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => null, 'achievement_category_id' => $aircraft, 'achievement_difficulty_id'=> $moderate, ], + [ + 'internal_name' => 'aircraft.twin_engine', + 'name' => 'Twinsies', + 'short_description' => 'Fly on a twin-engine aircraft.', + 'long_description' => '', + 'icon' => $icon, + 'threshold' => null, + 'progressive' => false, + 'difficulty_description' => 'Most planes in service today meet this criteria!', + 'achievement_category_id' => $aircraft, + 'achievement_difficulty_id'=> $easy, + ], [ 'internal_name' => 'aircraft.tri_engine', 'name' => 'Triple Threat', 'short_description' => 'Fly on a tri-engine aircraft.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => 'Most tri-jets are out of service nowadays, and tri-props even rarer', 'achievement_category_id' => $aircraft, @@ -573,9 +732,10 @@ return new class extends Migration [ 'internal_name' => 'aircraft.smaller_manufacturer', 'name' => 'Break the Duopoly', - 'short_description' => 'Fly on an aircraft from a manufacturer other than Boeing or Airbus.', + 'short_description' => 'Fly on a scheduled flight on an aircraft from a manufacturer other than Boeing or Airbus.', 'long_description' => '', 'icon' => $icon, + 'threshold' => null, 'progressive' => false, 'difficulty_description' => null, 'achievement_category_id' => $aircraft, @@ -592,6 +752,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 18, 'difficulty_description' => null, 'achievement_category_id' => $airlinesAndAlliances, 'achievement_difficulty_id'=> $hard, @@ -603,6 +764,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 16, 'difficulty_description' => null, 'achievement_category_id' => $airlinesAndAlliances, 'achievement_difficulty_id'=> $hard, @@ -614,6 +776,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 26, 'difficulty_description' => null, 'achievement_category_id' => $airlinesAndAlliances, 'achievement_difficulty_id'=> $hard, @@ -625,6 +788,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 4, 'difficulty_description' => null, 'achievement_category_id' => $airlinesAndAlliances, 'achievement_difficulty_id'=> $hard, @@ -640,6 +804,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 26, 'difficulty_description' => 'Some letters have very few airlines with a matching IATA code, requiring creative routing.', 'achievement_category_id' => $funChallenges, 'achievement_difficulty_id' => $hard, @@ -651,6 +816,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 26, 'difficulty_description' => 'Certain letters — particularly Q, X, and Z — have extremely limited airport coverage, making this a serious challenge.', 'achievement_category_id' => $funChallenges, 'achievement_difficulty_id' => $nearImposs, @@ -666,6 +832,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 6, 'difficulty_description' => null, 'achievement_category_id' => $countriesAndContinents, 'achievement_difficulty_id' => $hard, @@ -677,6 +844,7 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, + 'threshold' => 7, 'difficulty_description' => 'Antarctica has no commercial scheduled service — expect a specialist charter or expedition flight.', 'achievement_category_id' => $countriesAndContinents, 'achievement_difficulty_id' => $nearImposs, @@ -689,7 +857,8 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, - 'difficulty_description' => 'There are 15 unique continent pairs (excluding Antarctica)', + 'threshold' => 21, + 'difficulty_description' => 'There are 21 unique continent pairs (excluding Antarctica)', 'achievement_category_id' => $countriesAndContinents, 'achievement_difficulty_id' => $hard, ], @@ -700,7 +869,8 @@ return new class extends Migration 'long_description' => '', 'icon' => $icon, 'progressive' => true, - 'difficulty_description' => "30 different intercontinental flights required, good luck.", + 'threshold' => 36, + 'difficulty_description' => "36 different intercontinental flights required, good luck.", 'achievement_category_id' => $countriesAndContinents, 'achievement_difficulty_id' => $nearImposs, ], @@ -711,6 +881,8 @@ return new class extends Migration DB::table('achievements')->insert($achievements); } + + public function down(): void { // Reverse user_achievements