'datetime', 'ends_at' => 'datetime', 'promotion_starts_at' => 'datetime', 'promotion_ends_at' => 'datetime', 'submission_starts_at' => 'datetime', 'submission_ends_at' => 'datetime', 'published_at' => 'datetime', 'is_featured' => 'boolean', 'is_active_campaign' => 'boolean', 'is_homepage_featured' => 'boolean', 'campaign_priority' => 'integer', 'accepts_submissions' => 'boolean', 'allow_readd_after_removal' => 'boolean', 'submission_note_enabled' => 'boolean', 'community_section_enabled' => 'boolean', 'is_recurring' => 'boolean', 'edition_year' => 'integer', 'linked_challenge_id' => 'integer', 'show_linked_challenge_section' => 'boolean', 'show_linked_challenge_entries' => 'boolean', 'show_linked_challenge_winners' => 'boolean', 'show_linked_challenge_finalists' => 'boolean', 'auto_grant_challenge_world_rewards' => 'boolean', 'hidden_linked_challenge_artwork_ids_json' => 'array', 'related_tags_json' => 'array', 'section_order_json' => 'array', 'section_visibility_json' => 'array', 'recap_article_id' => 'integer', 'recap_stats_snapshot_json' => 'array', 'recap_published_at' => 'datetime', ]; protected static function booted(): void { $flushRecurrenceCache = static function (): void { static::$canonicalRecurrenceEditionIds = []; }; static::saved($flushRecurrenceCache); static::deleted($flushRecurrenceCache); static::restored($flushRecurrenceCache); } public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by_user_id'); } public function parentWorld(): BelongsTo { return $this->belongsTo(self::class, 'parent_world_id'); } public function linkedChallenge(): BelongsTo { return $this->belongsTo(GroupChallenge::class, 'linked_challenge_id'); } public function recapArticle(): BelongsTo { return $this->belongsTo(NewsArticle::class, 'recap_article_id'); } public function archiveEditions(): HasMany { return $this->hasMany(self::class, 'parent_world_id')->orderByDesc('edition_year')->orderByDesc('starts_at'); } public function worldRelations(): HasMany { return $this->hasMany(WorldRelation::class)->orderBy('section_key')->orderBy('sort_order')->orderBy('id'); } public function worldSubmissions(): HasMany { return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at'); } public function editorialSuggestionStates(): HasMany { return $this->hasMany(WorldEditorialSuggestionState::class)->orderByDesc('updated_at')->orderByDesc('id'); } public function worldRewardGrants(): HasMany { return $this->hasMany(WorldRewardGrant::class)->orderByDesc('granted_at')->orderByDesc('id'); } public function scopePublished(Builder $query): Builder { return $query ->where('status', self::STATUS_PUBLISHED) ->where(function (Builder $builder): void { $builder->whereNull('published_at') ->orWhere('published_at', '<=', now()); }); } public function scopePubliclyVisible(Builder $query): Builder { return $query ->whereIn('status', [self::STATUS_PUBLISHED, self::STATUS_ARCHIVED]) ->where(function (Builder $builder): void { $builder->whereNull('published_at') ->orWhere('published_at', '<=', now()); }); } public function scopeCurrent(Builder $query): Builder { return $query ->published() ->where(function (Builder $builder): void { $builder->whereNull('starts_at') ->orWhere('starts_at', '<=', now()); }) ->where(function (Builder $builder): void { $builder->whereNull('ends_at') ->orWhere('ends_at', '>=', now()); }); } public function scopeUpcoming(Builder $query): Builder { return $query ->published() ->whereNotNull('starts_at') ->where('starts_at', '>', now()); } public function scopeCampaignActive(Builder $query): Builder { $now = now()->toDateTimeString(); return $query ->published() ->where('is_active_campaign', true) ->where(function (Builder $builder) use ($now): void { $builder->whereRaw('COALESCE(promotion_starts_at, starts_at) IS NULL') ->orWhereRaw('COALESCE(promotion_starts_at, starts_at) <= ?', [$now]); }) ->where(function (Builder $builder) use ($now): void { $builder->whereRaw('COALESCE(promotion_ends_at, ends_at) IS NULL') ->orWhereRaw('COALESCE(promotion_ends_at, ends_at) >= ?', [$now]); }); } public function scopeCampaignUpcoming(Builder $query): Builder { return $query ->published() ->where('is_active_campaign', true) ->whereRaw('COALESCE(promotion_starts_at, starts_at) > ?', [now()->toDateTimeString()]); } public function scopeHomepageFeatured(Builder $query): Builder { return $query->where('is_homepage_featured', true); } public function scopeArchive(Builder $query): Builder { return $query ->publiclyVisible() ->where(function (Builder $builder): void { $builder->where('status', self::STATUS_ARCHIVED) ->orWhere(function (Builder $expired): void { $expired->whereNotNull('ends_at') ->where('ends_at', '<', now()); }); }); } public function isPubliclyVisible(): bool { if (! in_array($this->status, [self::STATUS_PUBLISHED, self::STATUS_ARCHIVED], true)) { return false; } if ($this->published_at && $this->published_at->isFuture()) { return false; } return true; } public function isCurrent(): bool { if (! $this->isPubliclyVisible()) { return false; } if ($this->starts_at && $this->starts_at->isFuture()) { return false; } if ($this->ends_at && $this->ends_at->isPast()) { return false; } return true; } public function isAcceptingSubmissions(): bool { if (! $this->isPubliclyVisible() || ! $this->accepts_submissions || ! $this->allowsCreatorParticipation()) { return false; } if ($this->submission_starts_at && $this->submission_starts_at->isFuture()) { return false; } if ($this->submission_ends_at && $this->submission_ends_at->isPast()) { return false; } return true; } public function effectivePromotionStartsAt(): ?Carbon { return $this->promotion_starts_at ?? $this->starts_at; } public function effectivePromotionEndsAt(): ?Carbon { return $this->promotion_ends_at ?? $this->ends_at; } public function isActiveCampaign(): bool { if (! $this->isPubliclyVisible() || ! (bool) $this->is_active_campaign || (string) $this->status !== self::STATUS_PUBLISHED) { return false; } $startsAt = $this->effectivePromotionStartsAt(); $endsAt = $this->effectivePromotionEndsAt(); if ($startsAt && $startsAt->isFuture()) { return false; } if ($endsAt && $endsAt->isPast()) { return false; } return true; } public function isUpcomingCampaign(): bool { if (! $this->isPubliclyVisible() || ! (bool) $this->is_active_campaign || (string) $this->status !== self::STATUS_PUBLISHED) { return false; } $startsAt = $this->effectivePromotionStartsAt(); return $startsAt ? $startsAt->isFuture() : false; } public function isEndingSoon(?int $days = null): bool { if (! $this->isActiveCampaign()) { return false; } $endsAt = $this->effectivePromotionEndsAt(); if (! $endsAt) { return false; } $threshold = now()->addDays($days ?? (int) config('worlds.campaign_ending_soon_days', 5)); return $endsAt->greaterThanOrEqualTo(now()) && $endsAt->lessThanOrEqualTo($threshold); } public function isEndedEdition(): bool { return (string) $this->status === self::STATUS_ARCHIVED || ($this->ends_at && $this->ends_at->isPast()); } public function hasPublishedRecap(): bool { return (string) $this->recap_status === self::RECAP_STATUS_PUBLISHED && $this->recap_published_at !== null; } public function hasRecapDraftContent(): bool { return trim((string) ($this->recap_title ?? '')) !== '' || trim((string) ($this->recap_summary ?? '')) !== '' || trim((string) ($this->recap_intro ?? '')) !== '' || trim((string) ($this->recap_cover_path ?? '')) !== '' || (int) ($this->recap_article_id ?? 0) > 0 || ! empty($this->recap_stats_snapshot_json); } public function recapCoverUrl(): ?string { $path = trim((string) ($this->recap_cover_path ?: $this->cover_path ?: '')); if ($path === '') { return null; } if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { return $path; } return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); } public function teaserImageUrl(): ?string { $path = trim((string) ($this->teaser_image_path ?: $this->cover_path ?: '')); if ($path === '') { return null; } if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { return $path; } return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); } public function teaserTitle(): string { $title = trim((string) ($this->teaser_title ?? '')); return $title !== '' ? $title : (string) $this->title; } public function teaserSummary(): ?string { $summary = trim((string) ($this->teaser_summary ?? '')); if ($summary !== '') { return $summary; } $fallback = trim((string) ($this->summary ?? '')); return $fallback !== '' ? $fallback : null; } public function allowsCreatorParticipation(): bool { return in_array((string) $this->participation_mode, [ self::PARTICIPATION_MODE_MANUAL_APPROVAL, self::PARTICIPATION_MODE_AUTO_ADD, ], true); } public function submissionStartsAsLive(): bool { return (string) $this->participation_mode === self::PARTICIPATION_MODE_AUTO_ADD; } public function coverUrl(): ?string { $path = trim((string) $this->cover_path); if ($path === '') { return null; } if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { return $path; } return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); } public function ogImageUrl(): ?string { $path = trim((string) ($this->og_image_path ?: $this->cover_path ?: '')); if ($path === '') { return null; } if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { return $path; } return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); } public function publicUrl(): string { if (! $this->is_recurring || trim((string) $this->recurrence_key) === '') { return route('worlds.show', ['world' => $this->slug]); } if ($this->isCanonicalEdition()) { return $this->familyUrl(); } if ($this->edition_year !== null) { return route('worlds.editions.show', ['world' => $this->recurrence_key, 'year' => $this->edition_year]); } return $this->familyUrl(); } public function familySlug(): string { return trim((string) ($this->recurrence_key ?: $this->slug)); } public function familyUrl(): string { return route('worlds.show', ['world' => $this->familySlug()]); } public function editionUrl(): ?string { if (! $this->is_recurring || trim((string) $this->recurrence_key) === '' || $this->edition_year === null) { return null; } return route('worlds.editions.show', ['world' => $this->recurrence_key, 'year' => $this->edition_year]); } public function isCanonicalEdition(): bool { if (! $this->is_recurring || trim((string) $this->recurrence_key) === '') { return true; } return static::canonicalEditionIdForRecurrence((string) $this->recurrence_key) === (int) $this->id; } public function sectionOrder(): array { $defaults = array_values(array_filter(config('worlds.default_section_order', []), 'is_string')); $custom = array_values(array_filter($this->section_order_json ?? [], 'is_string')); return array_values(array_unique(array_merge($custom, $defaults))); } public function sectionVisibility(): array { $defaults = collect(array_keys((array) config('worlds.sections', []))) ->mapWithKeys(fn (string $key): array => [$key => true]) ->all(); $custom = collect((array) $this->section_visibility_json) ->mapWithKeys(fn ($value, $key): array => [(string) $key => (bool) $value]) ->all(); return array_merge($defaults, $custom); } private static function canonicalEditionIdForRecurrence(string $recurrenceKey): ?int { if (array_key_exists($recurrenceKey, static::$canonicalRecurrenceEditionIds)) { return static::$canonicalRecurrenceEditionIds[$recurrenceKey]; } $canonical = static::selectCanonicalEdition( static::query() ->publiclyVisible() ->where('recurrence_key', $recurrenceKey) ->get() ); return static::$canonicalRecurrenceEditionIds[$recurrenceKey] = $canonical ? (int) $canonical->id : null; } private static function selectCanonicalEdition(EloquentCollection $editions): ?self { return $editions ->sortBy([ fn (self $world): int => (string) $world->status === self::STATUS_PUBLISHED ? 0 : 1, fn (self $world): int => $world->isCurrent() ? 0 : 1, fn (self $world): int => -1 * (int) ($world->edition_year ?? 0), fn (self $world): int => -1 * ($world->starts_at?->getTimestamp() ?? $world->published_at?->getTimestamp() ?? 0), fn (self $world): int => -1 * (int) $world->id, ]) ->first(); } }