diff --git a/apps/web/src/app/App.test.tsx b/apps/web/src/app/App.test.tsx index f2865d4..8745441 100644 --- a/apps/web/src/app/App.test.tsx +++ b/apps/web/src/app/App.test.tsx @@ -114,11 +114,18 @@ describe('App Chip setup wiring', () => { expect(content).toContain('dynastyIndicator={{ seasonYear: chipDockSeason, coachName: chipCoachName }}'); }); +<<<<<<< HEAD it('mounts Sprint 46 atmosphere emitters beside the app shell controllers', () => { expect(content).toContain("import { EraTransitionEmitter } from '../features/dynasty-era/EraTransitionEmitter'"); expect(content).toContain("import { ChampionshipParadeEmitter } from '../features/playoffs/ChampionshipParadeEmitter'"); expect(content).toContain(''); expect(content).toContain(''); expect(content).toContain(''); +======= + it('registers the Sprint 46 achievements gallery route additively', () => { + expect(content).toContain("const LazyAchievementsGallery = lazy(async () => ({ default: (await import('../features/franchise/AchievementsGallery')).AchievementsGallery }));"); + expect(content).toContain("path: '/franchise/achievements'"); + expect(content).toContain('routeTree.addChildren([achievementsRoute]);'); +>>>>>>> 9ca31c9 (Sprint 46: Add achievements gallery) }); }); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 8c86df6..49ae27c 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -128,6 +128,7 @@ const LazyHallOfFameDirectory = lazy(async () => ({ default: (await import('../f const LazyTrophyRoom = lazy(async () => ({ default: (await import('../features/franchise/TrophyRoom')).TrophyRoom })); const LazyEraHall = lazy(async () => ({ default: (await import('../features/franchise/EraHall')).EraHall })); const LazyMvpPlaqueWall = lazy(async () => ({ default: (await import('../features/franchise/MvpPlaqueWall')).MvpPlaqueWall })); +const LazyAchievementsGallery = lazy(async () => ({ default: (await import('../features/franchise/AchievementsGallery')).AchievementsGallery })); const LazyPlayoffLoreDirectory = lazy(async () => ({ default: (await import('../features/playoffs/PlayoffLoreDirectory')).PlayoffLoreDirectory })); const LazyDynastyChronicle = lazy(async () => ({ default: (await import('../features/franchise/DynastyChronicle')).DynastyChronicle })); const LazyLockerRoom = lazy(async () => ({ default: (await import('../features/locker-room/LockerRoom')).LockerRoom })); @@ -1795,6 +1796,16 @@ const settingsRoute = createRoute({ component: SettingsScreen, }); +const achievementsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/franchise/achievements', + component: () => ( + + + + ), +}); + const routeTree = rootRoute.addChildren([ indexRoute, rosterRoute, lockerRoomRoute, contractsRoute, capLabRoute, frontOfficeRoute, endorsementsRoute, tradesRoute, tradeBlockRoute, watchListRoute, @@ -1809,6 +1820,8 @@ routeTree.addChildren([...(routeTree.children ?? []), trophyRoomRoute, eraHallRo routeTree.addChildren([...(routeTree.children ?? []), mvpPlaqueWallRoute]); +routeTree.addChildren([...(routeTree.children ?? []), achievementsRoute]); + const hashHistory = createHashHistory(); const router = createRouter({ routeTree, history: hashHistory }); diff --git a/apps/web/src/features/franchise/AchievementsGallery.test.tsx b/apps/web/src/features/franchise/AchievementsGallery.test.tsx new file mode 100644 index 0000000..27d695d --- /dev/null +++ b/apps/web/src/features/franchise/AchievementsGallery.test.tsx @@ -0,0 +1,83 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import type { Achievement } from '@mfd/engine'; +import { AchievementsGalleryView } from './AchievementsGallery'; +import { AchievementMedalSvg } from './achievementMedalSvg'; + +function achievement(overrides: Partial): Achievement { + return { + id: 'dynasty:first_championship', + title: 'First Championship', + description: 'Win your first title.', + category: 'dynasty', + tier: 'gold', + condition: { type: 'championships', threshold: 1 }, + unlockedYear: 2030, + unlockedWeek: 22, + icon: 'trophy', + ...overrides, + }; +} + +const ACHIEVEMENTS: Achievement[] = [ + achievement({ id: 'dynasty:first_championship', title: 'First Championship', tier: 'gold', unlockedYear: 2030 }), + achievement({ id: 'roster:homegrown', title: 'Homegrown', category: 'roster', tier: 'silver', unlockedYear: null, unlockedWeek: null }), + achievement({ id: 'draft:scout_elite', title: 'Scout Elite', category: 'draft', tier: 'bronze', unlockedYear: 2029 }), +]; + +describe('AchievementsGallery', () => { + it('renders the Achievements header', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('ACHIEVEMENTS'); + expect(markup).toContain('Every milestone earned, every banner raised.'); + }); + + it('renders one card per achievement in props', () => { + const markup = renderToStaticMarkup(); + + expect(markup.match(/data-achievement-card=/g)).toHaveLength(3); + }); + + it('filters Unlocked achievements only', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('First Championship'); + expect(markup).toContain('Scout Elite'); + expect(markup).not.toContain('Homegrown'); + }); + + it('filters Locked achievements only', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('Homegrown'); + expect(markup).not.toContain('First Championship'); + }); + + it('renders the gold medal SVG for tier gold', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-medal-tier="gold"'); + expect(markup).toContain('data-medal-laurel="true"'); + }); + + it('renders a lock overlay for locked medals', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-medal-locked="true"'); + expect(markup).toContain('aria-label="Locked medal"'); + }); + + it('shows the correct unlocked counter', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('2 of 3 unlocked'); + }); + + it('renders the empty state when zero achievements are unlocked', () => { + const locked = ACHIEVEMENTS.map((entry) => ({ ...entry, unlockedYear: null, unlockedWeek: null })); + const markup = renderToStaticMarkup(); + + expect(markup).toContain('No achievements yet. Build something legendary.'); + }); +}); diff --git a/apps/web/src/features/franchise/AchievementsGallery.tsx b/apps/web/src/features/franchise/AchievementsGallery.tsx new file mode 100644 index 0000000..62ad171 --- /dev/null +++ b/apps/web/src/features/franchise/AchievementsGallery.tsx @@ -0,0 +1,198 @@ +import { useMemo, useState } from 'react'; +import type { Achievement, AchievementCategory } from '@mfd/engine'; +import { PixelBadge, PixelButton, PixelPanel } from '@mfd/design-system/components'; +import { selectAchievements, useGameStore } from '../../app/store/game-store'; +import { + PixelScreenHeader, + autoGrid, + monoSm, + screenStackStyle, +} from '../shared/pixelUi'; +import { AchievementMedalSvg } from './achievementMedalSvg'; + +type GalleryFilter = 'all' | 'unlocked' | 'locked' | AchievementCategory; +type GallerySort = 'recent' | 'tier' | 'category'; + +const FILTERS: Array<{ id: GalleryFilter; label: string }> = [ + { id: 'all', label: 'All' }, + { id: 'unlocked', label: 'Unlocked' }, + { id: 'locked', label: 'Locked' }, + { id: 'dynasty', label: 'Dynasty' }, + { id: 'roster', label: 'Roster' }, + { id: 'draft', label: 'Draft' }, + { id: 'financial', label: 'Financial' }, + { id: 'coaching', label: 'Coaching' }, + { id: 'narrative', label: 'Narrative' }, + { id: 'records', label: 'Records' }, + { id: 'milestones', label: 'Milestones' }, + { id: 'hidden', label: 'Hidden' }, +]; + +const SORTS: Array<{ id: GallerySort; label: string }> = [ + { id: 'recent', label: 'Recently Unlocked' }, + { id: 'tier', label: 'Tier Gold to Bronze' }, + { id: 'category', label: 'Category' }, +]; + +const TIER_ORDER: Record = { + platinum: 0, + gold: 1, + silver: 2, + bronze: 3, +}; + +function isUnlocked(achievement: Achievement): boolean { + return achievement.unlockedYear !== null; +} + +function filterAchievements(achievements: Achievement[], filter: GalleryFilter): Achievement[] { + if (filter === 'all') return achievements; + if (filter === 'unlocked') return achievements.filter(isUnlocked); + if (filter === 'locked') return achievements.filter((achievement) => !isUnlocked(achievement)); + return achievements.filter((achievement) => achievement.category === filter); +} + +function sortAchievements(achievements: Achievement[], sort: GallerySort): Achievement[] { + const copy = [...achievements]; + if (sort === 'tier') { + return copy.sort((left, right) => TIER_ORDER[left.tier] - TIER_ORDER[right.tier] || left.title.localeCompare(right.title)); + } + if (sort === 'category') { + return copy.sort((left, right) => left.category.localeCompare(right.category) || left.title.localeCompare(right.title)); + } + return copy.sort((left, right) => { + const rightYear = right.unlockedYear ?? Number.MIN_SAFE_INTEGER; + const leftYear = left.unlockedYear ?? Number.MIN_SAFE_INTEGER; + if (rightYear !== leftYear) return rightYear - leftYear; + const rightWeek = right.unlockedWeek ?? Number.MIN_SAFE_INTEGER; + const leftWeek = left.unlockedWeek ?? Number.MIN_SAFE_INTEGER; + if (rightWeek !== leftWeek) return rightWeek - leftWeek; + return left.title.localeCompare(right.title); + }); +} + +function criteriaText(achievement: Achievement): string { + const type = achievement.condition.type.replaceAll('_', ' '); + return `Criteria: ${type} ${achievement.condition.threshold}`; +} + +function tierVariant(tier: Achievement['tier']): 'gold' | 'cyan' | 'green' | 'default' { + if (tier === 'platinum' || tier === 'gold') return 'gold'; + if (tier === 'silver') return 'cyan'; + if (tier === 'bronze') return 'green'; + return 'default'; +} + +function categoryLabel(category: AchievementCategory): string { + return category.replaceAll('_', ' '); +} + +export function AchievementsGalleryView({ + achievements, + initialFilter = 'all', + initialSort = 'recent', +}: { + achievements: Achievement[]; + initialFilter?: GalleryFilter; + initialSort?: GallerySort; +}) { + const [filter, setFilter] = useState(initialFilter); + const [sort, setSort] = useState(initialSort); + const unlockedCount = achievements.filter(isUnlocked).length; + const visibleAchievements = useMemo( + () => sortAchievements(filterAchievements(achievements, filter), sort), + [achievements, filter, sort], + ); + + return ( +
+ + {`${unlockedCount} of ${achievements.length} unlocked`} + Medal Gallery + + )} + /> + + {unlockedCount === 0 ? ( + +
+ No achievements yet. Build something legendary. +
+
+ ) : null} + + +
+
+ {FILTERS.map((entry) => ( + setFilter(entry.id)}> + {entry.label} + + ))} +
+
+ {SORTS.map((entry) => ( + setSort(entry.id)}> + {entry.label} + + ))} +
+
+
+ +
+ {visibleAchievements.map((achievement) => { + const unlocked = isUnlocked(achievement); + return ( +
+ +
+
+ {achievement.tier} + {categoryLabel(achievement.category)} + {unlocked ? Unlocked : Locked} +
+
+ {achievement.title} +
+
+ {achievement.description} +
+
+ {criteriaText(achievement)} +
+ {unlocked ? ( +
+ Year {achievement.unlockedYear}{achievement.unlockedWeek !== null ? ` // Week ${achievement.unlockedWeek}` : ''} +
+ ) : null} +
+
+ ); + })} +
+
+ ); +} + +export function AchievementsGallery() { + const achievements = useGameStore(selectAchievements); + return ; +} diff --git a/apps/web/src/features/franchise/achievementMedalSvg.tsx b/apps/web/src/features/franchise/achievementMedalSvg.tsx new file mode 100644 index 0000000..e7ddf80 --- /dev/null +++ b/apps/web/src/features/franchise/achievementMedalSvg.tsx @@ -0,0 +1,76 @@ +import { Lock } from 'lucide-react'; +import type { Achievement } from '@mfd/engine'; + +export type MedalTier = Extract | 'platinum'; + +function medalColor(tier: MedalTier): string { + if (tier === 'silver') return 'var(--mfd-cyan)'; + if (tier === 'bronze') return 'var(--mfd-orange)'; + return 'var(--mfd-gold)'; +} + +export function AchievementMedalSvg({ + tier, + locked, + title, +}: { + tier: MedalTier; + locked: boolean; + title: string; +}) { + const displayTier = tier === 'platinum' ? 'gold' : tier; + const color = locked ? 'var(--mfd-text-faint)' : medalColor(tier); + const dim = locked ? '0.42' : '1'; + + return ( + + + + + {displayTier === 'silver' ? ( + + + + + + + + + + + ) : null} + {displayTier === 'gold' ? ( + + + + + + + + + + + ) : null} + + + {locked ? ( + + + + ) : null} + + ); +}