import { API_VERSION, deriveLevel, getCollectionKey, seedBadgeDefinitions, seedCollectionItems, seedLeaderboardRivals, seedMatches, seedStadiums, seedTeams, seedTriviaQuestions } from '@fan-passport/shared'; import type { ActionResponse, AddMemoryRequest, AnswerTriviaRequest, CollectItemRequest, CollectionItem, ContentResponse, CreateUserResponse, ItemType, LeaderboardEntry, MemoryMood, PassportState, PassportStats, Prediction, SubmitPredictionRequest, UserCollectionEntry, UserProfile, AchievementEvent } from '@fan-passport/shared'; import { randomUUID } from 'node:crypto'; import { AppError } from './errors.js'; import { applyChallengeRewards, buildChallengeProgress } from './gamification.js'; import type { GamificationRecord } from './gamification.js'; interface PassportRecord extends GamificationRecord {} const collectionPoints: Record = { team: 20, stadium: 25, match: 30, sticker: 10 }; function nowISO(): string { return new Date().toISOString(); } function todayISODate(now = new Date()): string { return now.toISOString().slice(0, 10); } function id(prefix: string): string { return `${prefix}-${randomUUID()}`; } function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function dateDifferenceInDays(fromDate: string, toDate: string): number { const from = Date.parse(`${fromDate}T00:00:00.000Z`); const to = Date.parse(`${toDate}T00:00:00.000Z`); if (Number.isNaN(from) || Number.isNaN(to)) { return 0; } return Math.round((to - from) / 86_400_000); } function confidence(value: number): 1 | 2 | 3 | 4 | 5 { if (![1, 2, 3, 4, 5].includes(value)) { throw new AppError(400, 'INVALID_CONFIDENCE', 'Prediction confidence must be an integer from 1 to 5.'); } return value as 1 | 2 | 3 | 4 | 5; } export class DemoStore { private readonly users = new Map(); getContent(): ContentResponse { return { version: API_VERSION, teams: clone(seedTeams), stadiums: clone(seedStadiums), matches: clone(seedMatches), triviaQuestions: clone(seedTriviaQuestions), collectionItems: clone(seedCollectionItems), badges: clone(seedBadgeDefinitions), challenges: clone( buildChallengeProgress({ user: { id: 'content-preview', displayName: 'Content Preview', country: 'Global', createdAt: nowISO(), points: 0, level: 1, streakDays: 0, lastActiveDate: todayISODate() }, collections: [], triviaAnswers: [], predictions: [], memories: [], badges: [], achievements: [], challengeCompletions: {} }).map((progress) => { const challenge = seedChallengeDefinitionsById(progress.challengeId); return challenge; }).filter(Boolean) ) }; } reset(): void { this.users.clear(); } createUser(displayName: string, country?: string): CreateUserResponse { const trimmedName = displayName.trim(); if (trimmedName.length < 2) { throw new AppError(400, 'INVALID_DISPLAY_NAME', 'Display name must be at least 2 characters.'); } const now = nowISO(); const user: UserProfile = { id: id('user'), displayName: trimmedName, country: country?.trim() || 'Global', createdAt: now, points: 0, level: 1, streakDays: 1, lastActiveDate: todayISODate(new Date(now)) }; const record: PassportRecord = { user, collections: [], triviaAnswers: [], predictions: [], memories: [], badges: [], achievements: [], challengeCompletions: {} }; this.users.set(user.id, record); return { user: clone(user), passport: this.getPassport(user.id) }; } getPassport(userId: string): PassportState { const record = this.requireUser(userId); const leaderboard = this.getLeaderboard(userId); const userLeaderboardEntry = leaderboard.find((entry) => entry.userId === userId); const challengeProgress = buildChallengeProgress(record); const stats = this.buildStats(record, userLeaderboardEntry?.rank ?? leaderboard.length); return { user: clone(record.user), collections: clone(record.collections), triviaAnswers: clone(record.triviaAnswers), predictions: clone(record.predictions), memories: clone(record.memories), badges: clone(record.badges), challengeProgress, achievements: clone(record.achievements), leaderboard, stats }; } getLeaderboard(currentUserId?: string): LeaderboardEntry[] { const demoEntries = seedLeaderboardRivals.map((entry) => ({ ...entry })); const userEntries = [...this.users.values()].map((record) => ({ userId: record.user.id, displayName: record.user.displayName, country: record.user.country, points: record.user.points, badges: record.badges.length, completedChallenges: Object.keys(record.challengeCompletions).length, avatarEmoji: '🎟️' })); return [...demoEntries, ...userEntries] .sort((a, b) => { if (b.points !== a.points) return b.points - a.points; if (b.badges !== a.badges) return b.badges - a.badges; return a.displayName.localeCompare(b.displayName); }) .map((entry, index) => ({ ...entry, rank: index + 1, isCurrentUser: entry.userId === currentUserId })); } collectItem(request: CollectItemRequest): ActionResponse { const record = this.requireUser(request.userId); const item = this.requireCollectionItem(request.itemType, request.contentId); const key = getCollectionKey(request.itemType, request.contentId); const alreadyCollected = record.collections.some((entry) => getCollectionKey(entry.itemType, entry.contentId) === key); if (alreadyCollected) { return this.finalizeAction(record, [], `${item.title} is already in your passport. Duplicate XP was not awarded.`, nowISO()); } const now = nowISO(); const points = collectionPoints[request.itemType]; const entry: UserCollectionEntry = { id: id('collection'), itemType: request.itemType, contentId: request.contentId, collectedAt: now, pointsAwarded: points, source: request.source ?? 'manual' }; record.collections.push(entry); record.user.points += points; record.user.level = deriveLevel(record.user.points); const event = this.addAchievement(record, { type: 'collection', title: `${item.title} collected`, description: `${item.description} +${points} XP`, points, createdAt: now }); return this.finalizeAction(record, [event], `${item.title} collected. +${points} XP.`, now); } answerTrivia(questionId: string, request: AnswerTriviaRequest): ActionResponse { const record = this.requireUser(request.userId); const question = seedTriviaQuestions.find((candidate) => candidate.id === questionId); if (!question) { throw new AppError(404, 'TRIVIA_NOT_FOUND', `Trivia question ${questionId} was not found.`); } const selectedOption = question.options.find((option) => option.id === request.selectedOptionId); if (!selectedOption) { throw new AppError(400, 'TRIVIA_OPTION_NOT_FOUND', `Option ${request.selectedOptionId} is not valid for this question.`); } const existing = record.triviaAnswers.find((answer) => answer.questionId === questionId); if (existing) { return this.finalizeAction( record, [], `You already answered this trivia question. ${question.explanation}`, nowISO() ); } const now = nowISO(); const correct = request.selectedOptionId === question.correctOptionId; const points = correct ? question.points : 5; record.triviaAnswers.push({ questionId, selectedOptionId: request.selectedOptionId, correct, answeredAt: now, pointsAwarded: points }); record.user.points += points; record.user.level = deriveLevel(record.user.points); const event = this.addAchievement(record, { type: 'trivia', title: correct ? 'Trivia answered correctly' : 'Trivia attempt logged', description: `${question.question} ${question.explanation} +${points} XP`, points, createdAt: now }); const message = correct ? `Correct! ${question.explanation} +${points} XP.` : `Not quite. ${question.explanation} +${points} participation XP.`; return this.finalizeAction(record, [event], message, now); } submitPrediction(request: SubmitPredictionRequest): ActionResponse { const record = this.requireUser(request.userId); const match = seedMatches.find((candidate) => candidate.id === request.matchId); if (!match) { throw new AppError(404, 'MATCH_NOT_FOUND', `Match ${request.matchId} was not found.`); } if (match.status !== 'scheduled') { throw new AppError(409, 'PREDICTION_CLOSED', 'Predictions can only be submitted for scheduled matches.'); } if (![match.homeTeamId, match.awayTeamId].includes(request.predictedWinnerTeamId)) { throw new AppError(400, 'INVALID_PREDICTION_TEAM', 'Prediction winner must be one of the two teams in the match.'); } const now = nowISO(); const existing = record.predictions.find((prediction) => prediction.matchId === request.matchId); const giantKilling = match.underdogTeamId === request.predictedWinnerTeamId; if (existing) { existing.predictedWinnerTeamId = request.predictedWinnerTeamId; existing.confidence = confidence(request.confidence); existing.giantKilling = giantKilling; existing.updatedAt = now; const event = this.addAchievement(record, { type: 'prediction', title: 'Prediction updated', description: `${this.matchLabel(match.id)} prediction updated before kick-off. Duplicate prediction XP was not awarded.`, points: 0, createdAt: now }); return this.finalizeAction( record, [event], giantKilling ? 'Prediction updated to a giant-killing pick. Challenge progress checked; duplicate prediction XP was not awarded.' : 'Prediction updated before kick-off. Duplicate prediction XP was not awarded.', now ); } const points = giantKilling ? 25 : 15; const prediction: Prediction = { id: id('prediction'), userId: record.user.id, matchId: request.matchId, predictedWinnerTeamId: request.predictedWinnerTeamId, confidence: confidence(request.confidence), giantKilling, status: 'submitted', createdAt: now, updatedAt: now, pointsAwarded: points }; record.predictions.push(prediction); record.user.points += points; record.user.level = deriveLevel(record.user.points); const predictedTeam = seedTeams.find((team) => team.id === request.predictedWinnerTeamId); const event = this.addAchievement(record, { type: 'prediction', title: giantKilling ? 'Giant-killing prediction submitted' : 'Prediction submitted', description: `${predictedTeam?.name ?? 'Selected team'} backed in ${this.matchLabel(match.id)}. +${points} XP`, points, createdAt: now }); return this.finalizeAction( record, [event], giantKilling ? `${predictedTeam?.name ?? 'Underdog'} backed for a giant killing. +${points} XP.` : `${predictedTeam?.name ?? 'Team'} prediction submitted. +${points} XP.`, now ); } addMemory(request: AddMemoryRequest): ActionResponse { const record = this.requireUser(request.userId); if (request.matchId && !seedMatches.some((match) => match.id === request.matchId)) { throw new AppError(404, 'MATCH_NOT_FOUND', `Match ${request.matchId} was not found.`); } const now = nowISO(); const points = 12; record.memories.push({ id: id('memory'), title: request.title.trim(), note: request.note.trim(), matchId: request.matchId, mood: request.mood, createdAt: now, pointsAwarded: points }); record.user.points += points; record.user.level = deriveLevel(record.user.points); const event = this.addAchievement(record, { type: 'memory', title: `Memory saved: ${request.title.trim()}`, description: `${request.note.trim()} +${points} XP`, points, createdAt: now }); return this.finalizeAction(record, [event], `Memory saved. +${points} XP.`, now); } private requireUser(userId: string): PassportRecord { const record = this.users.get(userId); if (!record) { throw new AppError(404, 'USER_NOT_FOUND', `Passport user ${userId} was not found.`); } return record; } private requireCollectionItem(itemType: ItemType, contentId: string): CollectionItem { const item = seedCollectionItems.find((candidate) => candidate.itemType === itemType && candidate.contentId === contentId); if (!item) { throw new AppError(404, 'COLLECTION_ITEM_NOT_FOUND', `No ${itemType} collection item exists for ${contentId}.`); } return item; } private matchLabel(matchId: string): string { const match = seedMatches.find((candidate) => candidate.id === matchId); if (!match) { return matchId; } const home = seedTeams.find((team) => team.id === match.homeTeamId)?.name ?? match.homeTeamId; const away = seedTeams.find((team) => team.id === match.awayTeamId)?.name ?? match.awayTeamId; return `${home} vs ${away}`; } private addAchievement( record: PassportRecord, event: Omit ): AchievementEvent { const achievement: AchievementEvent = { ...event, id: id('achievement') }; record.achievements.unshift(achievement); return achievement; } private finalizeAction(record: PassportRecord, directEvents: AchievementEvent[], message: string, now: string): ActionResponse { this.touchActivity(record, now); const challengeEvents = applyChallengeRewards(record, now); return { passport: this.getPassport(record.user.id), achievementEvents: [...directEvents, ...challengeEvents], message }; } private touchActivity(record: PassportRecord, now: string): void { const today = todayISODate(new Date(now)); const dayDifference = dateDifferenceInDays(record.user.lastActiveDate, today); if (dayDifference === 1) { record.user.streakDays += 1; } else if (dayDifference > 1) { record.user.streakDays = 1; } record.user.lastActiveDate = today; } private buildStats(record: PassportRecord, rank: number): PassportStats { return { totalCollections: record.collections.length, teamsCollected: record.collections.filter((entry) => entry.itemType === 'team').length, stadiumsCollected: record.collections.filter((entry) => entry.itemType === 'stadium').length, matchesLogged: record.collections.filter((entry) => entry.itemType === 'match').length, stickersCollected: record.collections.filter((entry) => entry.itemType === 'sticker').length, correctTriviaAnswers: record.triviaAnswers.filter((answer) => answer.correct).length, predictionsSubmitted: record.predictions.length, giantKillingsPredicted: record.predictions.filter((prediction) => prediction.giantKilling).length, memoriesCreated: record.memories.length, completedChallenges: Object.keys(record.challengeCompletions).length, rank }; } } function seedChallengeDefinitionsById(challengeId: string) { return (awaitlessSeedChallenges()).find((challenge) => challenge.id === challengeId); } function awaitlessSeedChallenges() { return [ { id: 'challenge-kickoff-passport', title: 'Kick-off passport', description: 'Collect one item, answer trivia, submit one prediction, and save one memory.', kind: 'mixed', metric: 'complete_starter_actions', target: 4, points: 75, badgeId: 'badge-kickoff-passport' }, { id: 'challenge-group-b-complete', title: 'Complete every Group B team', description: 'Collect England, USA, Iran, and Wales to finish the seeded Group B page.', kind: 'collection', metric: 'collect_all_content_ids', itemType: 'team', contentIds: ['england', 'usa', 'iran', 'wales'], target: 4, points: 120, badgeId: 'badge-group-b-complete' }, { id: 'challenge-stadium-tour', title: 'Collect all stadiums', description: 'Collect every stadium stamp from the 2026 host-city tour.', kind: 'collection', metric: 'collect_all_content_ids', itemType: 'stadium', contentIds: [ 'metlife-stadium', 'sofi-stadium', 'att-stadium', 'mercedes-benz-stadium', 'nrg-stadium', 'lincoln-financial-field', 'lumen-field', 'levis-stadium', 'hard-rock-stadium', 'gillette-stadium', 'arrowhead-stadium', 'bc-place', 'bmo-field', 'estadio-azteca', 'estadio-akron', 'estadio-bbva' ], target: 16, points: 250, badgeId: 'badge-stadium-tour' }, { id: 'challenge-england-superfan', title: 'Watch all England matches', description: 'Log every seeded England group match in your passport.', kind: 'collection', metric: 'collect_all_content_ids', itemType: 'match', contentIds: ['match-england-usa', 'match-england-iran', 'match-wales-england'], target: 3, points: 150, badgeId: 'badge-england-superfan' }, { id: 'challenge-trivia-streak', title: 'Answer daily World Cup trivia', description: 'Answer three trivia questions to build a daily habit.', kind: 'trivia', metric: 'answer_trivia_count', target: 3, points: 90, badgeId: 'badge-trivia-streak' }, { id: 'challenge-giant-killing', title: 'Predict a giant killing', description: 'Back an underdog to win one seeded fixture.', kind: 'prediction', metric: 'submit_giant_killing_prediction', target: 1, points: 80, badgeId: 'badge-giant-killer' }, { id: 'challenge-sticker-rookie', title: 'Build a virtual sticker collection', description: 'Collect five virtual stickers for your album.', kind: 'collection', metric: 'collect_any', itemType: 'sticker', target: 5, points: 70, badgeId: 'badge-sticker-rookie' }, { id: 'challenge-memory-keeper', title: 'Save a tournament memory', description: 'Write down one moment you want to remember.', kind: 'memory', metric: 'add_memory_count', target: 1, points: 50, badgeId: 'badge-memory-keeper' } ] as const; }