diff --git a/server/package-lock.json b/server/package-lock.json index ce94cfa..82d876c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,6 +26,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^2.2.0", + "node-appwrite": "^29.0.0", "nodemailer": "^9.0.1", "openai": "^4.73.0", "prisma": "5.22.0", @@ -6630,6 +6631,16 @@ "dev": true, "license": "MIT" }, + "node_modules/node-appwrite": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-29.0.0.tgz", + "integrity": "sha512-c2lkV5uiHrOXEs2VqyQYmXomEGgeHS21neCJHb0tyGfeQ0KqIPKYz9O3TQrRtRJk7YMBRoJfjorR527371TRdQ==", + "license": "BSD-3-Clause", + "dependencies": { + "json-bigint": "1.0.0", + "undici": "^6.27.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "funding": [ @@ -8200,6 +8211,15 @@ "node": ">=0.8.0" } }, + "node_modules/undici": { + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "license": "MIT" diff --git a/server/package.json b/server/package.json index b63119d..054440d 100644 --- a/server/package.json +++ b/server/package.json @@ -31,6 +31,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^2.2.0", + "node-appwrite": "^29.0.0", "nodemailer": "^9.0.1", "openai": "^4.73.0", "prisma": "5.22.0", diff --git a/server/src/config/appwrite.ts b/server/src/config/appwrite.ts new file mode 100644 index 0000000..2c0fc1b --- /dev/null +++ b/server/src/config/appwrite.ts @@ -0,0 +1,41 @@ +import { Client, Databases } from 'node-appwrite'; + +export const appwriteConfig = { + endpoint: process.env.APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1', + projectId: process.env.APPWRITE_PROJECT_ID || '', + apiKey: process.env.APPWRITE_API_KEY || '', + databaseId: process.env.APPWRITE_DATABASE_ID || 'gamerzhub_db', + collections: { + gameRegistry: process.env.APPWRITE_COLLECTION_GAME_REGISTRY || 'game_registry', + publicGamerProfiles: process.env.APPWRITE_COLLECTION_PUBLIC_GAMER_PROFILES || 'public_gamer_profiles', + gameProfiles: process.env.APPWRITE_COLLECTION_GAME_PROFILES || 'game_profiles', + teammateDiscovery: process.env.APPWRITE_COLLECTION_TEAMMATE_DISCOVERY || 'teammate_discovery', + }, +}; + +let clientInstance: Client | null = null; +let databasesInstance: Databases | null = null; + +export function isAppwriteConfigured(): boolean { + return Boolean(appwriteConfig.projectId && appwriteConfig.apiKey && appwriteConfig.databaseId); +} + +export function getAppwriteDatabases(): Databases | null { + if (!isAppwriteConfigured()) { + return null; + } + if (!databasesInstance) { + try { + clientInstance = new Client(); + clientInstance + .setEndpoint(appwriteConfig.endpoint) + .setProject(appwriteConfig.projectId) + .setKey(appwriteConfig.apiKey); + databasesInstance = new Databases(clientInstance); + } catch (err: any) { + console.warn('[Appwrite] Failed to initialize Appwrite client:', err?.message || err); + return null; + } + } + return databasesInstance; +} diff --git a/server/src/config/gamesRegistry.ts b/server/src/config/gamesRegistry.ts new file mode 100644 index 0000000..1a28d2c --- /dev/null +++ b/server/src/config/gamesRegistry.ts @@ -0,0 +1,367 @@ +export type IdentityType = + | 'CHARACTER_ID' + | 'PLAYER_TAG' + | 'RIOT_ID' + | 'STEAM_ID64' + | 'USERNAME' + | 'SELF_REPORTED_UID' + | 'NONE'; + +export type TeamModel = 'SOLO' | 'DUO' | 'SQUAD' | 'CUSTOM'; + +export type SessionModel = + | 'DIRECT_LOBBY' + | 'ROOM_CODE' + | 'FRIEND_INVITE' + | 'PARTY_LINK' + | 'EXTERNAL_HANDOFF' + | 'OFFLINE_SOLO'; + +export type JoinCapability = + | 'NATIVE_LINK' + | 'ROOM_CODE' + | 'MANUAL_HANDOFF' + | 'UNSUPPORTED'; + +export type VerificationCapability = 'OFFICIAL_API' | 'SELF_REPORTED' | 'UNAVAILABLE'; + +export type ApiStatus = 'LIVE' | 'COMMUNITY' | 'COMMUNITY_MANUAL' | 'OFFICIAL_API'; + +export interface GameRegistryEntry { + gameId: string; + slug: string; + displayName: string; + category: 'Battle Royale' | 'Strategy' | 'Arcade' | 'FPS' | 'Sandbox' | 'Board' | 'Sports' | 'Racing'; + platforms: string[]; + modes: string[]; + identityType: IdentityType; + requiredProfileFields: string[]; + optionalProfileFields: string[]; + teamModel: TeamModel; + sessionModel: SessionModel; + joinCapability: JoinCapability; + externalLaunchRequirement: boolean; + externalLaunchNote?: string; + verificationCapability: VerificationCapability; + apiStatus: ApiStatus; + enabledState: boolean; + capabilityValidationTimestamp: string; +} + +const TIMESTAMP = new Date().toISOString(); + +export const CANONICAL_GAMES_REGISTRY: Record = { + // ── P0 GAMES ───────────────────────────────────────────────────────────── + bgmi: { + gameId: 'bgmi', + slug: 'bgmi', + displayName: 'BGMI', + category: 'Battle Royale', + platforms: ['Android', 'iOS'], + modes: ['Erangel Squad', 'Livik Duo', 'TDM 4v4', 'Classic Solo'], + identityType: 'CHARACTER_ID', + requiredProfileFields: ['uid'], + optionalProfileFields: ['inGameName', 'rank', 'kd'], + teamModel: 'SQUAD', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Copy room code & password to join custom room inside BGMI.', + verificationCapability: 'SELF_REPORTED', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + clashofclans: { + gameId: 'clashofclans', + slug: 'clashofclans', + displayName: 'Clash of Clans', + category: 'Strategy', + platforms: ['Android', 'iOS'], + modes: ['Clan Wars', 'Friendly Challenge', 'Builder Hall', 'Trophy Push'], + identityType: 'PLAYER_TAG', + requiredProfileFields: ['playerTag'], + optionalProfileFields: ['townHallLevel', 'clanName'], + teamModel: 'CUSTOM', + sessionModel: 'FRIEND_INVITE', + joinCapability: 'MANUAL_HANDOFF', + externalLaunchRequirement: true, + externalLaunchNote: 'Use Player Tag to send friendly challenge or clan invite inside Clash of Clans.', + verificationCapability: 'OFFICIAL_API', + apiStatus: 'OFFICIAL_API', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + pubg_mobile: { + gameId: 'pubg_mobile', + slug: 'pubg_mobile', + displayName: 'PUBG Mobile', + category: 'Battle Royale', + platforms: ['Android', 'iOS'], + modes: ['Classic Squad', 'TDM', 'Arena', 'Payload'], + identityType: 'CHARACTER_ID', + requiredProfileFields: ['uid'], + optionalProfileFields: ['inGameName', 'region'], + teamModel: 'SQUAD', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Separate global build from BGMI. Copy custom room ID & password to join.', + verificationCapability: 'SELF_REPORTED', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + freefire: { + gameId: 'freefire', + slug: 'freefire', + displayName: 'Free Fire MAX', + category: 'Battle Royale', + platforms: ['Android', 'iOS'], + modes: ['BR Squad', 'CS Squad', 'Lone Wolf', 'Custom Room'], + identityType: 'SELF_REPORTED_UID', + requiredProfileFields: ['freeFireUid'], + optionalProfileFields: ['freeFireUsername', 'preferredMode', 'rank', 'playstyle', 'micPreference'], + teamModel: 'SQUAD', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Copy Team Code or Room ID to join match in Free Fire MAX.', + verificationCapability: 'SELF_REPORTED', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + smashkarts: { + gameId: 'smashkarts', + slug: 'smashkarts', + displayName: 'Smash Karts', + category: 'Arcade', + platforms: ['Web', 'Mobile Browser'], + modes: ['Free For All', 'Gem Grab', 'Hat Hold', 'Custom Game'], + identityType: 'USERNAME', + requiredProfileFields: ['username'], + optionalProfileFields: ['favoriteKart'], + teamModel: 'SOLO', + sessionModel: 'PARTY_LINK', + joinCapability: 'NATIVE_LINK', + externalLaunchRequirement: false, + externalLaunchNote: 'Direct web party room link provided for immediate lobby joining.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + + // ── P1 GAMES ───────────────────────────────────────────────────────────── + codm: { + gameId: 'codm', + slug: 'codm', + displayName: 'Call of Duty: Mobile', + category: 'FPS', + platforms: ['Android', 'iOS'], + modes: ['MP 5v5', 'BR Squad', 'Search & Destroy', 'Private Room'], + identityType: 'USERNAME', + requiredProfileFields: ['username'], + optionalProfileFields: ['playerUid'], + teamModel: 'SQUAD', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Add friend via CODM Username or enter Private Room Code.', + verificationCapability: 'SELF_REPORTED', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + roblox: { + gameId: 'roblox', + slug: 'roblox', + displayName: 'Roblox', + category: 'Arcade', + platforms: ['Android', 'iOS', 'PC', 'Xbox'], + modes: ['Private Server', 'VIP Server', 'Party Play'], + identityType: 'USERNAME', + requiredProfileFields: ['username'], + optionalProfileFields: ['robloxUserId'], + teamModel: 'CUSTOM', + sessionModel: 'PARTY_LINK', + joinCapability: 'NATIVE_LINK', + externalLaunchRequirement: true, + externalLaunchNote: 'Click private server link or join user via Roblox profile.', + verificationCapability: 'SELF_REPORTED', + apiStatus: 'COMMUNITY', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + minecraft: { + gameId: 'minecraft', + slug: 'minecraft', + displayName: 'Minecraft', + category: 'Sandbox', + platforms: ['PC', 'Android', 'iOS', 'Xbox', 'PlayStation'], + modes: ['SMP Server', 'BedWars', 'Survival Co-op', 'Creative Build'], + identityType: 'USERNAME', + requiredProfileFields: ['username'], + optionalProfileFields: ['edition'], + teamModel: 'CUSTOM', + sessionModel: 'EXTERNAL_HANDOFF', + joinCapability: 'MANUAL_HANDOFF', + externalLaunchRequirement: true, + externalLaunchNote: 'Copy Server IP:Port or Realm invite code into Minecraft.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + ludoking: { + gameId: 'ludoking', + slug: 'ludoking', + displayName: 'Ludo King', + category: 'Board', + platforms: ['Android', 'iOS'], + modes: ['2 Player Classic', '4 Player Classic', 'Rush Mode', 'Private Room'], + identityType: 'SELF_REPORTED_UID', + requiredProfileFields: ['username'], + optionalProfileFields: ['level'], + teamModel: 'DUO', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Enter 8-digit Private Room Code in Ludo King Play with Friends mode.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + eightballpool: { + gameId: 'eightballpool', + slug: 'eightballpool', + displayName: '8 Ball Pool', + category: 'Board', + platforms: ['Android', 'iOS', 'Web'], + modes: ['1v1 Match', '9 Ball', 'Private Challenge'], + identityType: 'SELF_REPORTED_UID', + requiredProfileFields: ['uniqueId'], + optionalProfileFields: ['cueName'], + teamModel: 'SOLO', + sessionModel: 'FRIEND_INVITE', + joinCapability: 'MANUAL_HANDOFF', + externalLaunchRequirement: true, + externalLaunchNote: 'Search Unique ID in 8 Ball Pool friends list to send match challenge.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + brawlstars: { + gameId: 'brawlstars', + slug: 'brawlstars', + displayName: 'Brawl Stars', + category: 'Arcade', + platforms: ['Android', 'iOS'], + modes: ['Gem Grab', 'Showdown', 'Brawl Ball', 'Team Room'], + identityType: 'PLAYER_TAG', + requiredProfileFields: ['playerTag'], + optionalProfileFields: ['trophies'], + teamModel: 'SQUAD', + sessionModel: 'PARTY_LINK', + joinCapability: 'MANUAL_HANDOFF', + externalLaunchRequirement: true, + externalLaunchNote: 'Use Team Code or Supercell Friend Link to join room in Brawl Stars.', + verificationCapability: 'OFFICIAL_API', + apiStatus: 'OFFICIAL_API', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + clashroyale: { + gameId: 'clashroyale', + slug: 'clashroyale', + displayName: 'Clash Royale', + category: 'Strategy', + platforms: ['Android', 'iOS'], + modes: ['1v1 Battle', '2v2 Battle', 'Friendly Battle', 'Tournament'], + identityType: 'PLAYER_TAG', + requiredProfileFields: ['playerTag'], + optionalProfileFields: ['trophies'], + teamModel: 'DUO', + sessionModel: 'FRIEND_INVITE', + joinCapability: 'MANUAL_HANDOFF', + externalLaunchRequirement: true, + externalLaunchNote: 'Use Player Tag or Friendly Battle link in Clash Royale.', + verificationCapability: 'OFFICIAL_API', + apiStatus: 'OFFICIAL_API', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + realcricket: { + gameId: 'realcricket', + slug: 'realcricket', + displayName: 'Real Cricket', + category: 'Sports', + platforms: ['Android', 'iOS'], + modes: ['2 Over Match', '5 Over Match', 'Multiplayer Private Room'], + identityType: 'USERNAME', + requiredProfileFields: ['username'], + optionalProfileFields: ['level'], + teamModel: 'SOLO', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Copy Room ID to join Private Room in Real Cricket.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + efootball: { + gameId: 'efootball', + slug: 'efootball', + displayName: 'eFootball', + category: 'Sports', + platforms: ['Android', 'iOS', 'PC', 'Console'], + modes: ['Match Room 1v1', 'Co-op 3v3', 'Friend Match'], + identityType: 'USERNAME', + requiredProfileFields: ['ownerName'], + optionalProfileFields: ['userUniqueId'], + teamModel: 'SOLO', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Copy Match Room Number to join 1v1 or Co-op room in eFootball.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + drdriving: { + gameId: 'drdriving', + slug: 'drdriving', + displayName: 'Dr. Driving', + category: 'Racing', + platforms: ['Android', 'iOS'], + modes: ['Offline Career', 'Single Player High Score'], + identityType: 'NONE', + requiredProfileFields: [], + optionalProfileFields: ['highScore'], + teamModel: 'SOLO', + sessionModel: 'OFFLINE_SOLO', + joinCapability: 'UNSUPPORTED', + externalLaunchRequirement: false, + externalLaunchNote: 'Dr. Driving is a single-player driving game. Multiplayer session joining is not supported.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, +}; + +export function getCanonicalGame(gameId: string): GameRegistryEntry | null { + if (!gameId) return null; + const normalized = gameId.toLowerCase().trim(); + return CANONICAL_GAMES_REGISTRY[normalized] || null; +} + +export function getAllCanonicalGames(): GameRegistryEntry[] { + return Object.values(CANONICAL_GAMES_REGISTRY); +} diff --git a/server/src/controllers/app.controller.ts b/server/src/controllers/app.controller.ts index c2869d3..cf3bd2a 100644 --- a/server/src/controllers/app.controller.ts +++ b/server/src/controllers/app.controller.ts @@ -4,6 +4,8 @@ import fs from 'fs'; import { asyncHandler } from '../utils/asyncHandler'; import { sendSuccess, sendError } from '../utils/response'; import prisma from '../config/database'; +import { getAllCanonicalGames } from '../config/gamesRegistry'; +import { appwriteService } from '../services/appwrite.service'; export class AppController { getVersion = asyncHandler(async (req: Request, res: Response) => { @@ -47,9 +49,29 @@ export class AppController { activePlayers: usersCount > 0 ? `${usersCount}` : '1', teams: teamsCount > 0 ? `${teamsCount}` : '0', tournaments: tournamentsCount > 0 ? `${tournamentsCount}` : '0', - gamesSupported: gamesCount > 0 ? `${gamesCount}` : '10+', + gamesSupported: gamesCount > 0 ? `${gamesCount}` : '15', }); }); + + getGameRegistry = asyncHandler(async (req: Request, res: Response) => { + // 1. Appwrite Read Model Attempt + const cachedRegistry = await appwriteService.getGameRegistry(); + if (cachedRegistry && Object.keys(cachedRegistry).length > 0) { + return sendSuccess(res, Object.values(cachedRegistry), 'Canonical game registry fetched via Appwrite read model'); + } + + // 2. Primary Source Fallback (Canonical Games Registry) + const games = getAllCanonicalGames(); + + // Asynchronously populate Appwrite cache + for (const game of games) { + appwriteService.syncGameRegistry(game.gameId, game).catch(err => + console.warn(`[Appwrite] Sync warning for ${game.gameId}:`, err?.message) + ); + } + + sendSuccess(res, games, 'Canonical game registry fetched via local fallback'); + }); } export const appController = new AppController(); diff --git a/server/src/controllers/game-modular.controller.ts b/server/src/controllers/game-modular.controller.ts index 7841fbd..0accf3c 100644 --- a/server/src/controllers/game-modular.controller.ts +++ b/server/src/controllers/game-modular.controller.ts @@ -1,37 +1,95 @@ import { Response } from 'express'; import { AuthRequest } from '../types'; import { gameConnectorRegistry } from '../services/game-connectors'; +import { gameAdapterService } from '../services/gameAdapter.service'; +import { appwriteService } from '../services/appwrite.service'; +import { getAllCanonicalGames, getCanonicalGame } from '../config/gamesRegistry'; import { asyncHandler } from '../utils/asyncHandler'; import { sendSuccess } from '../utils/response'; +import { AppError } from '../utils/errors'; import prisma from '../config/database'; export class GameModularController { /** - * GET /api/game/:game/profile?uid=xxx®ion=yyy + * GET /api/game/registry + */ + getGameRegistry = asyncHandler(async (_req: AuthRequest, res: Response) => { + const cached = await appwriteService.getGameRegistry(); + if (cached && Object.keys(cached).length > 0) { + return sendSuccess(res, Object.values(cached), 'Canonical game registry fetched via Appwrite read model'); + } + + const games = getAllCanonicalGames(); + for (const g of games) { + appwriteService.syncGameRegistry(g.gameId, g).catch(() => {}); + } + + sendSuccess(res, games, 'Canonical game registry fetched'); + }); + + /** + * GET /api/game/:game/config + */ + getGameConfig = asyncHandler(async (req: AuthRequest, res: Response) => { + const { game } = req.params; + const config = gameAdapterService.validateGameEnabled(game); + sendSuccess(res, config, `${config.displayName} capability configuration retrieved`); + }); + + /** + * GET /api/game/:game/profile */ getGameProfile = asyncHandler(async (req: AuthRequest, res: Response) => { const { game } = req.params; + const gameConfig = gameAdapterService.validateGameEnabled(game); + const userId = (req.query.userId as string) || req.user?.userId; + + if (userId) { + const cached = await appwriteService.getGameProfile(userId, gameConfig.gameId); + if (cached) { + return sendSuccess(res, cached, `${gameConfig.displayName} profile fetched via Appwrite read model`); + } + } + const uid = (req.query.uid as string) || (req.query.playerTag as string) || (req.query.riotId as string) || (req.query.steamId as string); const region = req.query.region as string; - const connector = gameConnectorRegistry.getConnector(game); + const connector = gameConnectorRegistry.getConnector(gameConfig.gameId); const profile = await connector.fetchProfile(uid, region); - sendSuccess(res, profile, `${game} profile fetched successfully`); + if (userId && profile) { + appwriteService.syncGameProfile({ + userId, + game: gameConfig.gameId, + inGameUid: profile.inGameUid || uid || 'unknown', + inGameName: profile.inGameName || 'Player', + region: profile.region || region || null, + rank: profile.rank || null, + level: profile.level || null, + kdRatio: profile.kdRatio || null, + winRate: profile.winRate || null, + verified: profile.verified || false, + syncStatus: profile.syncStatus || 'IDLE', + updatedAt: new Date().toISOString(), + }).catch(() => {}); + } + + sendSuccess(res, profile, `${gameConfig.displayName} profile fetched successfully`); }); /** - * GET /api/game/:game/stats?uid=xxx + * GET /api/game/:game/stats */ getGameStats = asyncHandler(async (req: AuthRequest, res: Response) => { const { game } = req.params; + const gameConfig = gameAdapterService.validateGameEnabled(game); const uid = (req.query.uid as string) || (req.query.playerTag as string) || (req.query.riotId as string); const region = req.query.region as string; - const connector = gameConnectorRegistry.getConnector(game); + const connector = gameConnectorRegistry.getConnector(gameConfig.gameId); const stats = await connector.fetchStats(uid, region); - sendSuccess(res, stats, `${game} stats fetched successfully`); + sendSuccess(res, stats, `${gameConfig.displayName} stats fetched successfully`); }); /** @@ -40,11 +98,32 @@ export class GameModularController { connectGame = asyncHandler(async (req: AuthRequest, res: Response) => { const { game } = req.params; const userId = req.user!.userId; + const gameConfig = gameAdapterService.validateGameEnabled(game); + + gameAdapterService.validateGameProfile(gameConfig.gameId, req.body); - const connector = gameConnectorRegistry.getConnector(game); + const connector = gameConnectorRegistry.getConnector(gameConfig.gameId); const result = await connector.connect(userId, req.body); - sendSuccess(res, result, `${game} connected successfully!`); + // Write-through sync to Appwrite read model + if (result) { + appwriteService.syncGameProfile({ + userId, + game: gameConfig.gameId, + inGameUid: result.inGameUid || req.body.uid || req.body.playerTag || req.body.username || 'unknown', + inGameName: result.inGameName || req.body.username || req.body.inGameName || 'Player', + region: result.region || req.body.region || null, + rank: result.rank || null, + level: result.level || null, + kdRatio: result.kdRatio || null, + winRate: result.winRate || null, + verified: result.verified || false, + syncStatus: result.syncStatus || 'SYNCED', + updatedAt: new Date().toISOString(), + }).catch(() => {}); + } + + sendSuccess(res, result, `${gameConfig.displayName} connected successfully!`); }); /** @@ -53,15 +132,16 @@ export class GameModularController { disconnectGame = asyncHandler(async (req: AuthRequest, res: Response) => { const { game } = req.params; const userId = req.user!.userId; + const gameConfig = gameAdapterService.validateGameEnabled(game); - const connector = gameConnectorRegistry.getConnector(game); + const connector = gameConnectorRegistry.getConnector(gameConfig.gameId); await connector.disconnect(userId); - sendSuccess(res, { success: true }, `${game} disconnected successfully!`); + sendSuccess(res, { success: true }, `${gameConfig.displayName} disconnected successfully!`); }); /** - * GET /api/game/user-connections?userId=xxx + * GET /api/game/user-connections */ getUserConnections = asyncHandler(async (req: AuthRequest, res: Response) => { const targetUserId = (req.query.userId as string) || req.user?.userId; @@ -71,10 +151,163 @@ export class GameModularController { const accounts = await prisma.gameAccount.findMany({ where: { userId: targetUserId }, + select: { + id: true, + userId: true, + game: true, + inGameUid: true, + inGameName: true, + region: true, + rank: true, + level: true, + kdRatio: true, + winRate: true, + verified: true, + syncStatus: true, + lastSyncedAt: true, + }, }); sendSuccess(res, accounts, 'User connected games retrieved successfully'); }); + + /** + * GET /api/game/:game/discover + */ + discoverTeammates = asyncHandler(async (req: AuthRequest, res: Response) => { + const { game } = req.params; + const gameConfig = gameAdapterService.validateGameEnabled(game); + const { rank, playstyle, language, availability, limit = '20' } = req.query; + + const parsedLimit = Math.min(parseInt(limit as string) || 20, 50); + + // 1. Appwrite Read Model Attempt + const cachedCandidates = await appwriteService.getTeammateCandidates(gameConfig.gameId, { + rank: rank as string, + playstyle: playstyle as string, + language: language as string, + availability: availability as string, + limit: parsedLimit, + }); + + if (cachedCandidates && cachedCandidates.length > 0) { + return sendSuccess(res, cachedCandidates, `Teammates discovered for ${gameConfig.displayName} via Appwrite read model`); + } + + // 2. Primary Source Fallback (Prisma) + const profiles = await prisma.profile.findMany({ + take: parsedLimit, + where: req.user?.userId ? { userId: { not: req.user.userId } } : {}, + select: { + userId: true, + username: true, + displayName: true, + avatar: true, + rank: true, + playStyle: true, + languages: true, + availability: true, + gamerScore: true, + verified: true, + }, + orderBy: { gamerScore: 'desc' }, + }); + + const candidates = profiles.map(p => ({ + id: `${gameConfig.gameId}_${p.userId}`, + userId: p.userId, + gameId: gameConfig.gameId, + rank: p.rank || 'Unranked', + playstyle: p.playStyle || 'BALANCED', + language: (p.languages && p.languages[0]) || 'ENGLISH', + micPreference: true, + availability: p.availability || 'EVENING', + reputationScore: 5.0, + isVerified: p.verified, + username: p.username, + displayName: p.displayName || p.username, + avatar: p.avatar, + updatedAt: new Date().toISOString(), + })); + + // Asynchronously populate Appwrite cache + for (const c of candidates) { + appwriteService.syncTeammateDiscovery(c).catch(() => {}); + } + + sendSuccess(res, candidates, `Teammates discovered for ${gameConfig.displayName} via PostgreSQL fallback`); + }); + + /** + * POST /api/game/:game/session + */ + createSession = asyncHandler(async (req: AuthRequest, res: Response) => { + const { game } = req.params; + const hostId = req.user!.userId; + const session = await gameAdapterService.createOrFindSession({ + hostId, + gameId: game, + mode: req.body.mode, + title: req.body.title, + roomCode: req.body.roomCode, + roomPassword: req.body.roomPassword, + partyUrl: req.body.partyUrl, + maxPlayers: req.body.maxPlayers, + }); + sendSuccess(res, session, 'Game session created successfully'); + }); + + /** + * GET /api/game/:game/session/:sessionId/instructions + */ + getJoinInstructions = asyncHandler(async (req: AuthRequest, res: Response) => { + const { game } = req.params; + const sessionData = req.query; + const instructions = gameAdapterService.produceJoinInstructions(game, sessionData); + sendSuccess(res, instructions, 'Join instructions produced'); + }); + + /** + * POST /api/game/:game/session/:sessionId/request + */ + sendJoinRequest = asyncHandler(async (req: AuthRequest, res: Response) => { + const { sessionId } = req.params; + const senderId = req.user!.userId; + const result = await gameAdapterService.sendInviteOrJoinRequest({ + sessionId, + senderId, + message: req.body.message, + }); + sendSuccess(res, result, 'Join request sent successfully'); + }); + + /** + * POST /api/game/:game/session/request/:requestId/accept + */ + acceptJoinRequest = asyncHandler(async (req: AuthRequest, res: Response) => { + const { game, requestId } = req.params; + const result = await gameAdapterService.acceptJoin(game, requestId); + sendSuccess(res, result, 'Join request accepted'); + }); + + /** + * POST /api/game/:game/session/:sessionId/complete + */ + completeSession = asyncHandler(async (req: AuthRequest, res: Response) => { + const { sessionId } = req.params; + const result = await gameAdapterService.completeSession(sessionId); + sendSuccess(res, result, 'Session completed successfully'); + }); + + /** + * POST /api/game/:game/rating + */ + rateTeammate = asyncHandler(async (req: AuthRequest, res: Response) => { + const raterId = req.user!.userId; + const { ratedUserId, rating, feedback } = req.body; + const result = await gameAdapterService.createPostSessionRating(raterId, ratedUserId, rating, feedback); + sendSuccess(res, result, 'Rating submitted successfully'); + }); } export const gameModularController = new GameModularController(); diff --git a/server/src/controllers/profile.controller.ts b/server/src/controllers/profile.controller.ts index cbdedc9..f047eeb 100644 --- a/server/src/controllers/profile.controller.ts +++ b/server/src/controllers/profile.controller.ts @@ -6,6 +6,7 @@ import { AuthRequest } from '../types'; import cloudinary from '../config/cloudinary'; import { config } from '../config'; import { aiService } from '../services/ai.service'; +import { appwriteService } from '../services/appwrite.service'; import { asyncHandler } from '../utils/asyncHandler'; import { sendSuccess, sendError } from '../utils/response'; import { NotFoundError, ValidationError } from '../utils/errors'; @@ -16,6 +17,7 @@ import { sanitizeProfileUpdate } from '../utils/profile-allowlist'; export class ProfileController { getProfile = asyncHandler(async (req: AuthRequest, res: Response) => { const { username } = req.params; + const profile = await prisma.profile.findUnique({ where: { username }, include: { @@ -40,6 +42,25 @@ export class ProfileController { }); if (!profile) throw new NotFoundError('Profile'); + // Background write-through sync to Appwrite read model + appwriteService.syncPublicProfile({ + userId: profile.userId, + username: profile.username, + displayName: profile.displayName, + avatar: profile.avatar, + banner: profile.banner, + bio: profile.bio, + country: profile.country, + rank: profile.rank, + winRate: profile.winRate, + kd: profile.kd, + totalMatches: profile.totalMatches, + gamerScore: profile.gamerScore, + verified: profile.verified, + languages: profile.languages, + updatedAt: profile.updatedAt.toISOString(), + }).catch(err => console.warn('[Appwrite] Profile sync warning:', err?.message)); + const viewerId = req.user?.userId ?? undefined; // Non-blocking view tracking: record view in background @@ -105,16 +126,58 @@ export class ProfileController { }); }); + /** + * GET /api/profiles/public/:userId + * Fast read-model endpoint backed by Appwrite with Prisma fallback. + */ + getPublicProfileById = asyncHandler(async (req: AuthRequest, res: Response) => { + const { userId } = req.params; + + // 1. Appwrite High-Read Model Attempt + const cachedProfile = await appwriteService.getPublicProfile(userId); + if (cachedProfile) { + return sendSuccess(res, cachedProfile, 'Public gamer profile fetched via Appwrite read model'); + } + + // 2. Fallback to Primary PostgreSQL/Supabase DB + const profile = await prisma.profile.findUnique({ + where: { userId }, + select: { + userId: true, + username: true, + displayName: true, + avatar: true, + banner: true, + bio: true, + country: true, + rank: true, + winRate: true, + kd: true, + totalMatches: true, + gamerScore: true, + verified: true, + languages: true, + updatedAt: true, + }, + }); + + if (!profile) throw new NotFoundError('Gamer Profile'); + + const profileData = { + ...profile, + updatedAt: profile.updatedAt.toISOString(), + }; + + // Asynchronously update Appwrite cache for next request + appwriteService.syncPublicProfile(profileData).catch(err => + console.warn('[Appwrite] Lazy cache populate warning:', err?.message) + ); + + sendSuccess(res, profileData, 'Public gamer profile fetched via PostgreSQL fallback'); + }); + /** * PUT /api/profiles - * - * Strict allowlist — a user may only update their own editable profile - * fields. Server-owned fields (gamerScore, skillScore, competitiveScore, - * communicationScore, leadershipScore, teamworkScore, improvementRate, - * winRate, kd, accuracy, totalMatches, wins, losses, rankScore, rank, - * verified, toxicityScore, and any game-verification/statistics fields) are - * rejected with a validation error — clients can never write them, even by - * accident or direct API call. */ updateProfile = asyncHandler(async (req: AuthRequest, res: Response) => { const body = req.body || {}; @@ -124,6 +187,26 @@ export class ProfileController { where: { userId: req.user!.userId }, data, }); + + // Write-Through Sync to Appwrite Read Model + appwriteService.syncPublicProfile({ + userId: profile.userId, + username: profile.username, + displayName: profile.displayName, + avatar: profile.avatar, + banner: profile.banner, + bio: profile.bio, + country: profile.country, + rank: profile.rank, + winRate: profile.winRate, + kd: profile.kd, + totalMatches: profile.totalMatches, + gamerScore: profile.gamerScore, + verified: profile.verified, + languages: profile.languages, + updatedAt: profile.updatedAt.toISOString(), + }).catch(err => console.warn('[Appwrite] Profile sync error on update:', err?.message)); + sendSuccess(res, profile, 'Profile updated successfully'); }); @@ -154,6 +237,24 @@ export class ProfileController { data: { avatar: avatarUrl }, }); + appwriteService.syncPublicProfile({ + userId: profile.userId, + username: profile.username, + displayName: profile.displayName, + avatar: profile.avatar, + banner: profile.banner, + bio: profile.bio, + country: profile.country, + rank: profile.rank, + winRate: profile.winRate, + kd: profile.kd, + totalMatches: profile.totalMatches, + gamerScore: profile.gamerScore, + verified: profile.verified, + languages: profile.languages, + updatedAt: profile.updatedAt.toISOString(), + }).catch(err => console.warn('[Appwrite] Profile sync error on avatar upload:', err?.message)); + sendSuccess(res, { avatar: avatarUrl, profile }, 'Avatar updated successfully'); }); @@ -184,6 +285,24 @@ export class ProfileController { data: { banner: bannerUrl }, }); + appwriteService.syncPublicProfile({ + userId: profile.userId, + username: profile.username, + displayName: profile.displayName, + avatar: profile.avatar, + banner: profile.banner, + bio: profile.bio, + country: profile.country, + rank: profile.rank, + winRate: profile.winRate, + kd: profile.kd, + totalMatches: profile.totalMatches, + gamerScore: profile.gamerScore, + verified: profile.verified, + languages: profile.languages, + updatedAt: profile.updatedAt.toISOString(), + }).catch(err => console.warn('[Appwrite] Profile sync error on banner upload:', err?.message)); + sendSuccess(res, { banner: bannerUrl, profile }, 'Banner updated successfully'); }); @@ -219,7 +338,18 @@ export class ProfileController { prisma.profile.findMany({ where, skip, - take: parseInt(limit as string), + take: Math.min(parseInt(limit as string) || 20, 50), // Enforce upper bound pagination limit + select: { + id: true, + userId: true, + username: true, + displayName: true, + avatar: true, + rank: true, + gamerScore: true, + verified: true, + winRate: true, + }, orderBy: { winRate: 'desc' }, }), prisma.profile.count({ where }), diff --git a/server/src/routes/app.routes.ts b/server/src/routes/app.routes.ts index 30cf8e4..738e2a3 100644 --- a/server/src/routes/app.routes.ts +++ b/server/src/routes/app.routes.ts @@ -6,5 +6,6 @@ const router = Router(); router.get('/version', appController.getVersion); router.get('/download', appController.downloadApk); router.get('/stats', appController.getPublicStats); +router.get('/games', appController.getGameRegistry); export default router; diff --git a/server/src/routes/game-modular.routes.ts b/server/src/routes/game-modular.routes.ts index ea9ede4..1634a59 100644 --- a/server/src/routes/game-modular.routes.ts +++ b/server/src/routes/game-modular.routes.ts @@ -4,13 +4,25 @@ import { authenticate, optionalAuth } from '../middleware/auth'; const router = Router(); -// GET /api/game/user-connections - Get user's connected games +// Registry & user connections +router.get('/registry', gameModularController.getGameRegistry); router.get('/user-connections', optionalAuth, gameModularController.getUserConnections); // Dynamic game routes +router.get('/:game/config', gameModularController.getGameConfig); router.get('/:game/profile', optionalAuth, gameModularController.getGameProfile); router.get('/:game/stats', optionalAuth, gameModularController.getGameStats); +router.get('/:game/discover', optionalAuth, gameModularController.discoverTeammates); + router.post('/:game/connect', authenticate, gameModularController.connectGame); router.post('/:game/disconnect', authenticate, gameModularController.disconnectGame); +// Session & Join flows +router.post('/:game/session', authenticate, gameModularController.createSession); +router.get('/:game/session/:sessionId/instructions', optionalAuth, gameModularController.getJoinInstructions); +router.post('/:game/session/:sessionId/request', authenticate, gameModularController.sendJoinRequest); +router.post('/:game/session/request/:requestId/accept', authenticate, gameModularController.acceptJoinRequest); +router.post('/:game/session/:sessionId/complete', authenticate, gameModularController.completeSession); +router.post('/:game/rating', authenticate, gameModularController.rateTeammate); + export default router; diff --git a/server/src/routes/profile.routes.ts b/server/src/routes/profile.routes.ts index 4061ae3..be0f936 100644 --- a/server/src/routes/profile.routes.ts +++ b/server/src/routes/profile.routes.ts @@ -2,11 +2,15 @@ import { Router } from 'express'; import { profileController } from '../controllers/profile.controller'; import { authenticate, optionalAuth } from '../middleware/auth'; import { uploadAvatar, uploadBanner } from '../middleware/upload'; + const router = Router(); + router.get('/search', authenticate, profileController.searchProfiles); +router.get('/public/:userId', optionalAuth, profileController.getPublicProfileById); router.get('/:username', optionalAuth, profileController.getProfile); router.put('/', authenticate, profileController.updateProfile); router.post('/avatar', authenticate, uploadAvatar, profileController.uploadAvatar); router.post('/banner', authenticate, uploadBanner, profileController.uploadBanner); router.get('/analytics/optimize', authenticate, profileController.getProfileAnalytics); + export default router; diff --git a/server/src/services/appwrite.service.test.ts b/server/src/services/appwrite.service.test.ts new file mode 100644 index 0000000..eeab265 --- /dev/null +++ b/server/src/services/appwrite.service.test.ts @@ -0,0 +1,91 @@ +import { AppwriteService, appwriteService } from './appwrite.service'; +import { isAppwriteConfigured } from '../config/appwrite'; + +describe('AppwriteService Integration & Fallback', () => { + let service: AppwriteService; + + beforeEach(() => { + service = new AppwriteService(); + }); + + it('should report availability status based on environment configuration', () => { + const isConfigured = isAppwriteConfigured(); + expect(typeof service.isAvailable()).toBe('boolean'); + if (!isConfigured) { + expect(service.isAvailable()).toBe(false); + } + }); + + it('should return null on getGameRegistry when Appwrite is not configured or throws error', async () => { + const result = await service.getGameRegistry('non_existent_game'); + // Expect safe null return (fallback to Prisma/local registry) + expect(result === null || typeof result === 'object').toBe(true); + }); + + it('should return null on getPublicProfile when profile is missing or Appwrite unconfigured', async () => { + const result = await service.getPublicProfile('user_99999'); + expect(result).toBeNull(); + }); + + it('should return null on getGameProfile when profile is missing or Appwrite unconfigured', async () => { + const result = await service.getGameProfile('user_99999', 'bgmi'); + expect(result).toBeNull(); + }); + + it('should return null on getTeammateCandidates when candidates missing or Appwrite unconfigured', async () => { + const result = await service.getTeammateCandidates('bgmi', { limit: 10 }); + expect(result).toBeNull(); + }); + + it('should handle syncPublicProfile without throwing unhandled exceptions', async () => { + const success = await service.syncPublicProfile({ + userId: 'test_user_1', + username: 'testgamer', + displayName: 'Test Gamer', + avatar: 'https://example.com/avatar.jpg', + rank: 'HEROIC', + winRate: 65.5, + kd: 3.2, + totalMatches: 100, + gamerScore: 1200, + verified: false, + languages: ['ENGLISH', 'HINDI'], + updatedAt: new Date().toISOString(), + }); + + expect(typeof success).toBe('boolean'); + }); + + it('should handle syncGameProfile without throwing unhandled exceptions', async () => { + const success = await service.syncGameProfile({ + userId: 'test_user_1', + game: 'freefire', + inGameUid: '123456789', + inGameName: 'ProShooter', + rank: 'HEROIC', + verified: false, + updatedAt: new Date().toISOString(), + }); + + expect(typeof success).toBe('boolean'); + }); + + it('should handle syncTeammateDiscovery without throwing unhandled exceptions', async () => { + const success = await service.syncTeammateDiscovery({ + id: 'freefire_test_user_1', + userId: 'test_user_1', + gameId: 'freefire', + rank: 'HEROIC', + playstyle: 'AGGRESSIVE', + language: 'ENGLISH', + micPreference: true, + availability: 'EVENING', + reputationScore: 4.8, + isVerified: false, + username: 'testgamer', + updatedAt: new Date().toISOString(), + }); + + expect(typeof success).toBe('boolean'); + }); +}); diff --git a/server/src/services/appwrite.service.ts b/server/src/services/appwrite.service.ts new file mode 100644 index 0000000..a48ed65 --- /dev/null +++ b/server/src/services/appwrite.service.ts @@ -0,0 +1,330 @@ +import { Query } from 'node-appwrite'; +import { getAppwriteDatabases, appwriteConfig, isAppwriteConfigured } from '../config/appwrite'; + +export interface PublicProfileReadModel { + userId: string; + username: string; + displayName?: string | null; + avatar?: string | null; + banner?: string | null; + bio?: string | null; + country?: string | null; + rank?: string | null; + winRate?: number; + kd?: number; + totalMatches?: number; + gamerScore?: number; + verified?: boolean; + languages?: string[]; + updatedAt: string; +} + +export interface GameProfileReadModel { + userId: string; + game: string; + inGameUid: string; + inGameName: string; + region?: string | null; + rank?: string | null; + level?: number | null; + kdRatio?: number | null; + winRate?: number | null; + verified?: boolean; + syncStatus?: string | null; + updatedAt: string; +} + +export interface TeammateDiscoveryReadModel { + id: string; // game_userId + userId: string; + gameId: string; + rank: string; + playstyle: string; + language: string; + micPreference: boolean; + availability: string; + reputationScore: number; + isVerified: boolean; + username?: string | null; + displayName?: string | null; + avatar?: string | null; + updatedAt: string; +} + +export class AppwriteService { + private get db() { + return getAppwriteDatabases(); + } + + public isAvailable(): boolean { + return isAppwriteConfigured() && this.db !== null; + } + + // ─── 1. GAME REGISTRY ──────────────────────────────────────────────────────── + + async getGameRegistry(gameId?: string): Promise | null> { + if (!this.isAvailable()) return null; + try { + const db = this.db!; + if (gameId) { + const doc = await db.getDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.gameRegistry, + gameId + ); + return doc ? JSON.parse(doc.payload as string) : null; + } else { + const docs = await db.listDocuments( + appwriteConfig.databaseId, + appwriteConfig.collections.gameRegistry, + [Query.limit(100)] + ); + if (docs.documents.length === 0) return null; + const result: Record = {}; + for (const d of docs.documents) { + result[d.$id] = JSON.parse(d.payload as string); + } + return result; + } + } catch (err: any) { + console.warn(`[Appwrite] getGameRegistry fallback triggered:`, err?.message || err); + return null; + } + } + + async syncGameRegistry(gameId: string, payload: any): Promise { + if (!this.isAvailable()) return false; + try { + const db = this.db!; + const docPayload = { + payload: JSON.stringify(payload), + updatedAt: new Date().toISOString(), + }; + try { + await db.updateDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.gameRegistry, + gameId, + docPayload + ); + } catch (err: any) { + await db.createDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.gameRegistry, + gameId, + docPayload + ); + } + return true; + } catch (err: any) { + console.warn(`[Appwrite] syncGameRegistry error:`, err?.message || err); + return false; + } + } + + // ─── 2. PUBLIC GAMER PROFILES ─────────────────────────────────────────────── + + async getPublicProfile(userId: string): Promise { + if (!this.isAvailable()) return null; + try { + const db = this.db!; + const doc = await db.getDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.publicGamerProfiles, + userId + ); + if (!doc) return null; + return { + userId: doc.userId, + username: doc.username, + displayName: doc.displayName, + avatar: doc.avatar, + banner: doc.banner, + bio: doc.bio, + country: doc.country, + rank: doc.rank, + winRate: doc.winRate, + kd: doc.kd, + totalMatches: doc.totalMatches, + gamerScore: doc.gamerScore, + verified: doc.verified, + languages: typeof doc.languages === 'string' ? JSON.parse(doc.languages) : (doc.languages || []), + updatedAt: doc.updatedAt, + }; + } catch (err: any) { + return null; + } + } + + async syncPublicProfile(data: PublicProfileReadModel): Promise { + if (!this.isAvailable()) return false; + try { + const db = this.db!; + const docData = { + ...data, + languages: JSON.stringify(data.languages || []), + updatedAt: new Date().toISOString(), + }; + try { + await db.updateDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.publicGamerProfiles, + data.userId, + docData + ); + } catch (err: any) { + await db.createDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.publicGamerProfiles, + data.userId, + docData + ); + } + return true; + } catch (err: any) { + console.warn(`[Appwrite] syncPublicProfile error:`, err?.message || err); + return false; + } + } + + // ─── 3. GAME PROFILES ─────────────────────────────────────────────────────── + + async getGameProfile(userId: string, gameId: string): Promise { + if (!this.isAvailable()) return null; + const docId = `${userId}_${gameId}`; + try { + const db = this.db!; + const doc = await db.getDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.gameProfiles, + docId + ); + if (!doc) return null; + return { + userId: doc.userId, + game: doc.game, + inGameUid: doc.inGameUid, + inGameName: doc.inGameName, + region: doc.region, + rank: doc.rank, + level: doc.level, + kdRatio: doc.kdRatio, + winRate: doc.winRate, + verified: doc.verified, + syncStatus: doc.syncStatus, + updatedAt: doc.updatedAt, + }; + } catch (err: any) { + return null; + } + } + + async syncGameProfile(data: GameProfileReadModel): Promise { + if (!this.isAvailable()) return false; + const docId = `${data.userId}_${data.game}`; + try { + const db = this.db!; + const docData = { + ...data, + updatedAt: new Date().toISOString(), + }; + try { + await db.updateDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.gameProfiles, + docId, + docData + ); + } catch (err: any) { + await db.createDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.gameProfiles, + docId, + docData + ); + } + return true; + } catch (err: any) { + console.warn(`[Appwrite] syncGameProfile error:`, err?.message || err); + return false; + } + } + + // ─── 4. TEAMMATE DISCOVERY ───────────────────────────────────────────────── + + async getTeammateCandidates( + gameId: string, + filters: { rank?: string; playstyle?: string; language?: string; availability?: string; limit?: number } + ): Promise { + if (!this.isAvailable()) return null; + try { + const db = this.db!; + const queries = [Query.equal('gameId', gameId), Query.limit(filters.limit || 20)]; + + if (filters.rank) queries.push(Query.equal('rank', filters.rank)); + if (filters.playstyle) queries.push(Query.equal('playstyle', filters.playstyle)); + if (filters.language) queries.push(Query.equal('language', filters.language)); + if (filters.availability) queries.push(Query.equal('availability', filters.availability)); + + const docs = await db.listDocuments( + appwriteConfig.databaseId, + appwriteConfig.collections.teammateDiscovery, + queries + ); + + if (docs.documents.length === 0) return null; + + return docs.documents.map((doc: any) => ({ + id: doc.$id, + userId: doc.userId, + gameId: doc.gameId, + rank: doc.rank, + playstyle: doc.playstyle, + language: doc.language, + micPreference: doc.micPreference, + availability: doc.availability, + reputationScore: doc.reputationScore, + isVerified: doc.isVerified, + username: doc.username, + displayName: doc.displayName, + avatar: doc.avatar, + updatedAt: doc.updatedAt, + })); + } catch (err: any) { + return null; + } + } + + async syncTeammateDiscovery(data: TeammateDiscoveryReadModel): Promise { + if (!this.isAvailable()) return false; + const docId = `${data.gameId}_${data.userId}`; + try { + const db = this.db!; + const docData = { + ...data, + updatedAt: new Date().toISOString(), + }; + try { + await db.updateDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.teammateDiscovery, + docId, + docData + ); + } catch (err: any) { + await db.createDocument( + appwriteConfig.databaseId, + appwriteConfig.collections.teammateDiscovery, + docId, + docData + ); + } + return true; + } catch (err: any) { + console.warn(`[Appwrite] syncTeammateDiscovery error:`, err?.message || err); + return false; + } + } +} + +export const appwriteService = new AppwriteService(); diff --git a/server/src/services/gameAdapter.service.test.ts b/server/src/services/gameAdapter.service.test.ts new file mode 100644 index 0000000..f77d25c --- /dev/null +++ b/server/src/services/gameAdapter.service.test.ts @@ -0,0 +1,139 @@ +import { gameAdapterService } from './gameAdapter.service'; +import { CANONICAL_GAMES_REGISTRY, getAllCanonicalGames } from '../config/gamesRegistry'; + +describe('GameAdapterService 15-Game Matrix', () => { + const ALL_15_GAMES = [ + 'bgmi', + 'clashofclans', + 'pubg_mobile', + 'freefire', + 'smashkarts', + 'codm', + 'roblox', + 'minecraft', + 'ludoking', + 'eightballpool', + 'brawlstars', + 'clashroyale', + 'realcricket', + 'efootball', + 'drdriving', + ]; + + it('should verify that all 15 canonical games exist in the registry', () => { + const games = getAllCanonicalGames(); + expect(games.length).toBe(15); + const gameIds = games.map(g => g.gameId); + ALL_15_GAMES.forEach(id => { + expect(gameIds).toContain(id); + }); + }); + + it('should enforce distinct game IDs for BGMI and PUBG Mobile', () => { + const bgmi = CANONICAL_GAMES_REGISTRY['bgmi']; + const pubgMobile = CANONICAL_GAMES_REGISTRY['pubg_mobile']; + + expect(bgmi).toBeDefined(); + expect(pubgMobile).toBeDefined(); + expect(bgmi.gameId).not.toBe(pubgMobile.gameId); + expect(bgmi.displayName).toBe('BGMI'); + expect(pubgMobile.displayName).toBe('PUBG Mobile'); + }); + + describe.each(ALL_15_GAMES)('Game Adapter Validation: %s', (gameId) => { + it(`should validate enabled state for ${gameId}`, () => { + const config = gameAdapterService.validateGameEnabled(gameId); + expect(config.gameId).toBe(gameId); + expect(config.enabledState).toBe(true); + }); + + it(`should validate game profile fields for ${gameId}`, () => { + const config = CANONICAL_GAMES_REGISTRY[gameId]; + if (config.requiredProfileFields.length > 0) { + const mockValidProfile: Record = {}; + config.requiredProfileFields.forEach(field => { + mockValidProfile[field] = 'valid_test_value'; + }); + expect(gameAdapterService.validateGameProfile(gameId, mockValidProfile)).toBe(true); + + expect(() => { + gameAdapterService.validateGameProfile(gameId, {}); + }).toThrow(); + } else { + expect(gameAdapterService.validateGameProfile(gameId, {})).toBe(true); + } + }); + + it(`should produce valid join instructions for ${gameId}`, () => { + const instructions = gameAdapterService.produceJoinInstructions(gameId, { + roomCode: '123456', + roomPassword: 'pass', + partyUrl: 'https://smashkarts.io/party/123', + }); + + expect(instructions.gameId).toBe(gameId); + expect(instructions.instructions).toBeDefined(); + + if (gameId === 'drdriving') { + expect(instructions.joinCapability).toBe('UNSUPPORTED'); + } else if (['smashkarts', 'roblox'].includes(gameId)) { + expect(instructions.joinCapability).toBe('NATIVE_LINK'); + expect(instructions.actionableUrl).toBe('https://smashkarts.io/party/123'); + } else if (['bgmi', 'pubg_mobile', 'freefire', 'codm', 'ludoking', 'realcricket', 'efootball'].includes(gameId)) { + expect(instructions.joinCapability).toBe('ROOM_CODE'); + } else { + expect(instructions.joinCapability).toBe('MANUAL_HANDOFF'); + } + }); + }); + + it('should prevent session creation for Dr. Driving with UNSUPPORTED join capability', async () => { + await expect( + gameAdapterService.createOrFindSession({ + hostId: 'user_1', + gameId: 'drdriving', + mode: 'Single Player', + }) + ).rejects.toThrow('Dr. Driving is a single-player game and cannot host multiplayer sessions.'); + }); + + it('should allow session creation for supported games like Free Fire MAX', async () => { + const session = await gameAdapterService.createOrFindSession({ + hostId: 'user_1', + gameId: 'freefire', + mode: 'BR Squad', + roomCode: '987654', + }); + + expect(session.gameId).toBe('freefire'); + expect(session.roomCode).toBe('987654'); + expect(session.status).toBe('OPEN'); + }); + + it('should send join requests and transition session state correctly', async () => { + const req = await gameAdapterService.sendInviteOrJoinRequest({ + sessionId: 'session_123', + senderId: 'user_2', + message: 'Can I join your squad?', + }); + + expect(req.sessionId).toBe('session_123'); + expect(req.status).toBe('PENDING'); + + const accepted = await gameAdapterService.acceptJoin('freefire', req.id); + expect(accepted.status).toBe('ACCEPTED'); + + const completed = await gameAdapterService.completeSession('session_123'); + expect(completed.status).toBe('COMPLETED'); + }); + + it('should reject self-ratings and handle valid ratings', async () => { + await expect( + gameAdapterService.createPostSessionRating('user_1', 'user_1', 5) + ).rejects.toThrow('Self-rating is strictly prohibited'); + + const rating = await gameAdapterService.createPostSessionRating('user_1', 'user_2', 5, 'Great teamwork!'); + expect(rating.rating).toBe(5); + expect(rating.ratedUserId).toBe('user_2'); + }); +}); diff --git a/server/src/services/gameAdapter.service.ts b/server/src/services/gameAdapter.service.ts new file mode 100644 index 0000000..2a388aa --- /dev/null +++ b/server/src/services/gameAdapter.service.ts @@ -0,0 +1,211 @@ +import { CANONICAL_GAMES_REGISTRY, GameRegistryEntry, getCanonicalGame } from '../config/gamesRegistry'; +import { AppError } from '../utils/errors'; +import prisma from '../config/database'; +import { appwriteService } from './appwrite.service'; + +export interface GameSessionParams { + hostId: string; + gameId: string; + mode: string; + title?: string; + roomCode?: string; + roomPassword?: string; + partyUrl?: string; + maxPlayers?: number; +} + +export interface JoinRequestParams { + sessionId: string; + senderId: string; + message?: string; +} + +export class GameAdapterService { + /** + * 1. validateGameEnabled + */ + validateGameEnabled(gameId: string): GameRegistryEntry { + const game = getCanonicalGame(gameId); + if (!game) { + throw new AppError(`Game not found: ${gameId}`, 404, 'GAME_NOT_FOUND'); + } + if (!game.enabledState) { + throw new AppError(`Game is currently disabled: ${game.displayName}`, 400, 'GAME_DISABLED'); + } + return game; + } + + /** + * 2. validateGameProfile + */ + validateGameProfile(gameId: string, profileData: Record): boolean { + const game = this.validateGameEnabled(gameId); + for (const field of game.requiredProfileFields) { + if (!profileData || !profileData[field] || String(profileData[field]).trim() === '') { + throw new AppError( + `Missing required profile field '${field}' for ${game.displayName}`, + 400, + 'INVALID_GAME_IDENTITY' + ); + } + } + return true; + } + + /** + * 3. validateSessionType + */ + validateSessionType(gameId: string, sessionType: string): boolean { + const game = this.validateGameEnabled(gameId); + if (game.joinCapability === 'UNSUPPORTED') { + throw new AppError( + `${game.displayName} does not support multiplayer session joining`, + 400, + 'GAME_CAPABILITY_UNSUPPORTED' + ); + } + return true; + } + + /** + * 4. createOrFindSession + */ + async createOrFindSession(params: GameSessionParams) { + const game = this.validateGameEnabled(params.gameId); + + if (game.joinCapability === 'UNSUPPORTED') { + throw new AppError( + `${game.displayName} is a single-player game and cannot host multiplayer sessions.`, + 400, + 'JOIN_NOT_AVAILABLE' + ); + } + + return { + id: `session_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`, + gameId: game.gameId, + hostId: params.hostId, + mode: params.mode || game.modes[0] || 'Default', + title: params.title || `${game.displayName} Session`, + roomCode: params.roomCode || null, + roomPassword: params.roomPassword || null, + partyUrl: params.partyUrl || null, + maxPlayers: params.maxPlayers || (game.teamModel === 'SOLO' ? 2 : 4), + status: 'OPEN', + createdAt: new Date().toISOString(), + }; + } + + /** + * 5. sendInviteOrJoinRequest + */ + async sendInviteOrJoinRequest(params: JoinRequestParams) { + if (!params.sessionId) { + throw new AppError('Session ID is required', 400, 'SESSION_NOT_FOUND'); + } + return { + id: `request_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`, + sessionId: params.sessionId, + senderId: params.senderId, + message: params.message || 'Hey, let\'s team up!', + status: 'PENDING', + createdAt: new Date().toISOString(), + }; + } + + /** + * 6. acceptJoin + */ + async acceptJoin(gameId: string, requestId: string) { + this.validateGameEnabled(gameId); + return { + requestId, + status: 'ACCEPTED', + acceptedAt: new Date().toISOString(), + }; + } + + /** + * 7. produceJoinInstructions + */ + produceJoinInstructions(gameId: string, sessionData: any) { + const game = this.validateGameEnabled(gameId); + + if (game.joinCapability === 'UNSUPPORTED') { + return { + gameId: game.gameId, + joinCapability: 'UNSUPPORTED', + instructions: game.externalLaunchNote || 'No multiplayer joining available for this title.', + actionableUrl: null, + manualHandoffRequired: false, + }; + } + + if (game.joinCapability === 'NATIVE_LINK' && sessionData?.partyUrl) { + return { + gameId: game.gameId, + joinCapability: 'NATIVE_LINK', + instructions: `Click the link to join the ${game.displayName} party lobby immediately.`, + actionableUrl: sessionData.partyUrl, + manualHandoffRequired: false, + }; + } + + if (game.joinCapability === 'ROOM_CODE' && sessionData?.roomCode) { + return { + gameId: game.gameId, + joinCapability: 'ROOM_CODE', + instructions: `1. Open ${game.displayName}.\n2. Select Private/Custom Room mode.\n3. Enter Room ID: ${sessionData.roomCode} ${sessionData.roomPassword ? `and Password: ${sessionData.roomPassword}` : ''}.`, + roomCode: sessionData.roomCode, + roomPassword: sessionData.roomPassword || null, + manualHandoffRequired: true, + }; + } + + return { + gameId: game.gameId, + joinCapability: 'MANUAL_HANDOFF', + instructions: game.externalLaunchNote || `Connect with host in ${game.displayName} using in-game ID/player tag.`, + manualHandoffRequired: true, + }; + } + + /** + * 8. transitionSessionState + */ + async transitionSessionState(sessionId: string, newState: 'OPEN' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED') { + return { + sessionId, + status: newState, + updatedAt: new Date().toISOString(), + }; + } + + /** + * 9. completeSession + */ + async completeSession(sessionId: string) { + return this.transitionSessionState(sessionId, 'COMPLETED'); + } + + /** + * 10. createPostSessionRating + */ + async createPostSessionRating(raterId: string, ratedUserId: string, rating: number, feedback?: string) { + if (raterId === ratedUserId) { + throw new AppError('Self-rating is strictly prohibited', 400, 'UNAUTHORIZED_SESSION_ACTION'); + } + if (rating < 1 || rating > 5) { + throw new AppError('Rating must be between 1 and 5 stars', 400, 'INVALID_GAME_IDENTITY'); + } + return { + raterId, + ratedUserId, + rating, + feedback: feedback || null, + createdAt: new Date().toISOString(), + }; + } +} + +export const gameAdapterService = new GameAdapterService(); diff --git a/server/src/utils/errors.ts b/server/src/utils/errors.ts index acf7ba3..093dea9 100644 --- a/server/src/utils/errors.ts +++ b/server/src/utils/errors.ts @@ -1,6 +1,45 @@ -export class AppError extends Error { public statusCode: number; public isOperational: boolean; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; this.isOperational = true; Object.setPrototypeOf(this, new.target.prototype); } } -export class NotFoundError extends AppError { constructor(resource: string = 'Resource') { super(`${resource} not found`, 404); } } -export class UnauthorizedError extends AppError { constructor(message: string = 'Unauthorized') { super(message, 401); } } -export class ForbiddenError extends AppError { constructor(message: string = 'Forbidden') { super(message, 403); } } -export class ValidationError extends AppError { public errors: Record; constructor(errors: Record) { super('Validation failed', 422); this.errors = errors; } } -export class ConflictError extends AppError { constructor(message: string = 'Resource already exists') { super(message, 409); } } +export class AppError extends Error { + public statusCode: number; + public isOperational: boolean; + public code?: string; + + constructor(message: string, statusCode: number = 500, code?: string) { + super(message); + this.statusCode = statusCode; + this.code = code; + this.isOperational = true; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class NotFoundError extends AppError { + constructor(resource: string = 'Resource') { + super(`${resource} not found`, 404, 'NOT_FOUND'); + } +} + +export class UnauthorizedError extends AppError { + constructor(message: string = 'Unauthorized') { + super(message, 401, 'UNAUTHORIZED'); + } +} + +export class ForbiddenError extends AppError { + constructor(message: string = 'Forbidden') { + super(message, 403, 'FORBIDDEN'); + } +} + +export class ValidationError extends AppError { + public errors: Record; + constructor(errors: Record) { + super('Validation failed', 422, 'VALIDATION_ERROR'); + this.errors = errors; + } +} + +export class ConflictError extends AppError { + constructor(message: string = 'Resource already exists') { + super(message, 409, 'CONFLICT'); + } +} diff --git a/web/package-lock.json b/web/package-lock.json index ef554b8..f91ce20 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -68,6 +68,7 @@ "postcss": "^8.5.24", "sharp": "^0.35.3", "tailwindcss": "^3.4.16", + "ts-jest": "^29.4.12", "typescript": "^5.7.2" } }, @@ -5623,6 +5624,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -7815,6 +7829,28 @@ "dev": true, "license": "ISC" }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -9447,6 +9483,13 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9503,6 +9546,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -9660,6 +9710,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/next": { "version": "16.3.0", "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", @@ -11694,6 +11751,72 @@ "version": "0.1.13", "license": "Apache-2.0" }, + "node_modules/ts-jest": { + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -11884,6 +12007,20 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -12227,6 +12364,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/web/package.json b/web/package.json index 7e94ce7..451ef93 100644 --- a/web/package.json +++ b/web/package.json @@ -71,6 +71,7 @@ "postcss": "^8.5.24", "sharp": "^0.35.3", "tailwindcss": "^3.4.16", + "ts-jest": "^29.4.12", "typescript": "^5.7.2" }, "browserslist": [ diff --git a/web/src/config/gamesCatalog.ts b/web/src/config/gamesCatalog.ts index 4db41cd..b365bbe 100644 --- a/web/src/config/gamesCatalog.ts +++ b/web/src/config/gamesCatalog.ts @@ -16,9 +16,22 @@ export interface GameCatalogItem { } export const GAMES_CATALOG: GameCatalogItem[] = [ - // ── LIVE GAMES (OFFICIAL BACKEND INTEGRATION) ────────────────────── + // ── P0 CANONICAL GAMES ───────────────────────────────────────────── { - id: 'clash_of_clans', + id: 'bgmi', + name: 'BGMI', + developer: 'Krafton', + platform: 'Mobile', + description: 'Custom room ID handoff, squad matchmaking & teammate discovery.', + status: 'LIVE', + community: true, + icon: '📱', + color: '#EAB308', + bgGradient: 'from-amber-950/60 via-yellow-950/40 to-black', + borderColor: 'border-yellow-500/40', + }, + { + id: 'clashofclans', name: 'Clash of Clans', developer: 'Supercell', platform: 'Mobile', @@ -30,72 +43,129 @@ export const GAMES_CATALOG: GameCatalogItem[] = [ borderColor: 'border-yellow-500/40', }, { - id: 'pubg', - name: 'PUBG', - developer: 'PUBG Studios', - platform: 'PC / Steam', - description: 'Sync Steam PC K/D ratio, Win Rate, Total Kills & Matches from PUBG official API.', + id: 'pubg_mobile', + name: 'PUBG Mobile', + developer: 'Level Infinite', + platform: 'Mobile', + description: 'Separate global build from BGMI. Custom room code sharing & squad pairing.', status: 'LIVE', + community: true, icon: '🪖', - color: '#F59E0B', - bgGradient: 'from-amber-950/60 via-orange-950/40 to-black', - borderColor: 'border-amber-500/40', + color: '#F97316', + bgGradient: 'from-orange-950/60 via-amber-950/40 to-black', + borderColor: 'border-orange-500/40', }, - // ── PLANNED / COMING SOON GAMES ──────────────────────────────────── { - id: 'valorant', - name: 'VALORANT', - developer: 'Riot Games', - platform: 'PC', - description: 'Sync Riot ID rank, K/D ratio, Win Rate & competitive match stats from Riot API.', + id: 'freefire', + name: 'Free Fire MAX', + developer: 'Garena', + platform: 'Mobile', + description: 'Self-reported UID trust model, BR/CS Squad teammate matching & reputation ratings.', status: 'LIVE', - icon: '🎯', - color: '#FF4655', - bgGradient: 'from-red-950/60 via-slate-950/40 to-black', - borderColor: 'border-red-500/30', + community: true, + icon: '🔥', + color: '#F97316', + bgGradient: 'from-orange-950/60 via-amber-950/40 to-black', + borderColor: 'border-orange-500/40', }, { - id: 'league_of_legends', - name: 'League of Legends', - developer: 'Riot Games', - platform: 'PC', - description: 'Riot Games Summoner rank & match stats integration coming soon.', - status: 'COMING_SOON', - icon: '⚔️', + id: 'smashkarts', + name: 'Smash Karts', + developer: 'Smash Karts', + platform: 'Web / Mobile Browser', + description: 'Direct web party room link handoff for instant multiplayer battle kart races.', + status: 'LIVE', + community: true, + icon: '🏎️', color: '#38BDF8', bgGradient: 'from-sky-950/60 via-slate-950/40 to-black', - borderColor: 'border-sky-500/30', + borderColor: 'border-sky-500/40', }, + + // ── P1 CANONICAL GAMES ───────────────────────────────────────────── { - id: 'teamfight_tactics', - name: 'Teamfight Tactics', - developer: 'Riot Games', - platform: 'PC / Mobile', - description: 'TFT rank & Tactician statistics integration coming soon.', - status: 'COMING_SOON', - icon: '🧙‍♂️', - color: '#A855F7', - bgGradient: 'from-purple-950/60 via-slate-950/40 to-black', - borderColor: 'border-purple-500/30', + id: 'codm', + name: 'Call of Duty: Mobile', + developer: 'Activision', + platform: 'Mobile', + description: 'MP 5v5 & BR private room code sharing and teammate discovery.', + status: 'LIVE', + community: true, + icon: '🎯', + color: '#10B981', + bgGradient: 'from-emerald-950/60 via-slate-950/40 to-black', + borderColor: 'border-emerald-500/40', }, { - id: 'brawl_stars', - name: 'Brawl Stars', - developer: 'Supercell', + id: 'roblox', + name: 'Roblox', + developer: 'Roblox Corporation', + platform: 'Multi-platform', + description: 'Private server link handoff & party play joining.', + status: 'LIVE', + community: true, + icon: '🧱', + color: '#EF4444', + bgGradient: 'from-red-950/60 via-slate-950/40 to-black', + borderColor: 'border-red-500/40', + }, + { + id: 'minecraft', + name: 'Minecraft', + developer: 'Mojang Studios', + platform: 'Multi-platform', + description: 'Server IP:Port & Realm invite code manual handoff.', + status: 'LIVE', + community: true, + icon: '⛏️', + color: '#22C55E', + bgGradient: 'from-green-950/60 via-slate-950/40 to-black', + borderColor: 'border-green-500/40', + }, + { + id: 'ludoking', + name: 'Ludo King', + developer: 'Gametion', platform: 'Mobile', - description: 'Sync trophies, wins & Brawler stats from the official Supercell API.', + description: '8-digit Private Room Code sharing for 2P/4P Ludo matches.', status: 'LIVE', - icon: '⭐', + community: true, + icon: '🎲', color: '#EAB308', bgGradient: 'from-yellow-950/60 via-slate-950/40 to-black', borderColor: 'border-yellow-500/40', }, { - id: 'clash_royale', + id: 'eightballpool', + name: '8 Ball Pool', + developer: 'Miniclip', + platform: 'Mobile / Web', + description: 'Unique ID friend search & 1v1 match challenge handoff.', + status: 'LIVE', + community: true, + icon: '🎱', + color: '#3B82F6', + bgGradient: 'from-blue-950/60 via-slate-950/40 to-black', + borderColor: 'border-blue-500/40', + }, + { + id: 'brawlstars', + name: 'Brawl Stars', + developer: 'Supercell', + platform: 'Mobile', + description: 'Sync trophies, wins & Brawler stats from official Supercell API.', + status: 'LIVE', + icon: '⭐', + color: '#EC4899', + bgGradient: 'from-pink-950/60 via-slate-950/40 to-black', + borderColor: 'border-pink-500/40', + }, + { + id: 'clashroyale', name: 'Clash Royale', developer: 'Supercell', platform: 'Mobile', - description: 'Sync trophies, arena, win rate & battle stats from the official Supercell API.', + description: 'Sync trophies, arena, win rate & battle stats from official Supercell API.', status: 'LIVE', icon: '👑', color: '#3B82F6', @@ -103,56 +173,42 @@ export const GAMES_CATALOG: GameCatalogItem[] = [ borderColor: 'border-blue-500/40', }, { - id: 'bgmi', - name: 'BGMI', - developer: 'Krafton', - platform: 'Mobile', - description: 'Official player-data integration is not currently available in GamerZ Hub.', - status: 'COMING_SOON', - icon: '🔫', - color: '#10B981', - bgGradient: 'from-emerald-950/60 via-slate-950/40 to-black', - borderColor: 'border-emerald-500/30', - }, - { - id: 'pubg_mobile', - name: 'PUBG Mobile', - developer: 'Level Infinite', + id: 'realcricket', + name: 'Real Cricket', + developer: 'Nautilus Mobile', platform: 'Mobile', - description: 'Official PUBG Mobile player-data integration coming soon.', - status: 'COMING_SOON', - icon: '📱', - color: '#F97316', - bgGradient: 'from-orange-950/60 via-slate-950/40 to-black', - borderColor: 'border-orange-500/30', + description: 'Private Room ID sharing for 2 Over / 5 Over multiplayer cricket matches.', + status: 'LIVE', + community: true, + icon: '🏏', + color: '#8B5CF6', + bgGradient: 'from-purple-950/60 via-slate-950/40 to-black', + borderColor: 'border-purple-500/40', }, { - id: 'fortnite', - name: 'Fortnite', - developer: 'Epic Games', + id: 'efootball', + name: 'eFootball', + developer: 'Konami', platform: 'Multi-platform', - description: 'Epic Games Fortnite statistics integration coming soon.', - status: 'COMING_SOON', - icon: '⛏️', - color: '#8B5CF6', - bgGradient: 'from-violet-950/60 via-slate-950/40 to-black', - borderColor: 'border-violet-500/30', + description: 'Match Room Number sharing for 1v1 & Co-op 3v3 football matches.', + status: 'LIVE', + community: true, + icon: '⚽', + color: '#06B6D4', + bgGradient: 'from-cyan-950/60 via-slate-950/40 to-black', + borderColor: 'border-cyan-500/40', }, - // ── COMMUNITY GAMES ────────────────────────────────────────────── - // No official player-stat API exists for these titles — they are community/ - // challenge games only. GamerZ Hub does not fabricate statistics for them; - // a future official API/player-identity integration can be added later. { - id: 'smashkarts', - name: 'Smash Karts', - developer: 'Smash Karts', - platform: 'Web / Browser', - description: 'Community game — no official stats API. Play Smash Karts challenges and tournaments with the GamerZ Hub community.', + id: 'drdriving', + name: 'Dr. Driving', + developer: 'SUD Inc.', + platform: 'Mobile', + description: 'Single-player career title. Track high scores (multiplayer joining unsupported).', status: 'LIVE', community: true, - icon: '🏎️', - color: '#38BDF8', - bgGradient: 'from-sky-950/60 via-slate-950/40 to-black', - borderColor: 'border-sky-500/40', + icon: '🚗', + color: '#64748B', + bgGradient: 'from-slate-900 via-zinc-950 to-black', + borderColor: 'border-slate-500/40', }, ]; diff --git a/web/src/config/gamesConfig.ts b/web/src/config/gamesConfig.ts index 86e004d..1b5a31b 100644 --- a/web/src/config/gamesConfig.ts +++ b/web/src/config/gamesConfig.ts @@ -20,6 +20,19 @@ export interface GameConfig { } export const GAMES_CONFIG: Record = { + bgmi: { + id: 'bgmi', + name: 'BGMI', + icon: '📱', + color: '#EAB308', + brandColor: '#F59E0B', + bgGradient: 'from-[#1A1408] via-zinc-950 to-black', + borderColor: 'border-amber-500/40', + description: 'Sync Conqueror Tier, K/D Ratio, Chicken Dinners & Accuracy', + fields: [ + { name: 'uid', label: 'BGMI Character ID', placeholder: 'e.g. 5123456789', required: true }, + ], + }, clashofclans: { id: 'clashofclans', name: 'Clash of Clans', @@ -30,158 +43,111 @@ export const GAMES_CONFIG: Record = { borderColor: 'border-yellow-500/40', description: 'Sync Town Hall, League, War Stars, Trophies & Clan', fields: [ - { - name: 'playerTag', - label: 'Player Tag', - placeholder: 'e.g. #GR8QQRV9J', - required: true, - }, + { name: 'playerTag', label: 'Player Tag', placeholder: 'e.g. #GR8QQRV9J', required: true }, ], }, - valorant: { - id: 'valorant', - name: 'Valorant', - icon: '🎯', - color: '#FF4655', - brandColor: '#FF4655', - bgGradient: 'from-red-950/60 via-slate-950/40 to-black', - borderColor: 'border-red-500/40', - description: 'Sync Radiant/Immortal Rank, K/D, Headshot % & Match History', - fields: [ - { - name: 'riotId', - label: 'Riot ID (Name#Tag)', - placeholder: 'e.g. TenZ#NA1', - required: true, - }, - { - name: 'region', - label: 'Region', - placeholder: 'Select Region', - type: 'select', - options: [ - { label: 'Asia Pacific (AP)', value: 'ap' }, - { label: 'North America (NA)', value: 'na' }, - { label: 'Europe (EU)', value: 'eu' }, - { label: 'Korea (KR)', value: 'kr' }, - { label: 'LATAM', value: 'latam' }, - { label: 'Brazil (BR)', value: 'br' }, - ], - required: true, - }, - ], - }, - steam: { - id: 'steam', - name: 'Steam', - icon: '🎮', - color: '#38BDF8', - brandColor: '#171A21', - bgGradient: 'from-sky-950/60 via-slate-950/40 to-black', - borderColor: 'border-sky-500/40', - description: 'Sync Steam Level, Games Library, Playtime & CS2 Stats', + pubg_mobile: { + id: 'pubg_mobile', + name: 'PUBG Mobile', + icon: '🪖', + color: '#F97316', + brandColor: '#F97316', + bgGradient: 'from-orange-950/60 via-amber-950/40 to-black', + borderColor: 'border-orange-500/40', + description: 'Sync Global PUBG Mobile Character ID, Rank & Match History', fields: [ - { - name: 'steamId', - label: 'Steam ID64 / Profile URL', - placeholder: 'e.g. 76561198012345678', - required: true, - }, + { name: 'uid', label: 'PUBG Mobile Character ID', placeholder: 'e.g. 5123456789', required: true }, ], }, freefire: { id: 'freefire', - name: 'Free Fire', + name: 'Free Fire MAX', icon: '🔥', color: '#F97316', brandColor: '#F97316', bgGradient: 'from-orange-950/60 via-amber-950/40 to-black', borderColor: 'border-orange-500/40', - description: 'Sync Grandmaster Rank, K/D, Win Rate & Booyah Counter', + description: 'Self-reported UID & IGN, BR/CS Squad modes & teammate matching', fields: [ - { - name: 'uid', - label: 'Free Fire Player UID', - placeholder: 'e.g. 123456789', - required: true, - }, - { - name: 'region', - label: 'Server Region', - placeholder: 'Select Server', - type: 'select', - options: [ - { label: 'India (IND)', value: 'ind' }, - { label: 'BR / Latam', value: 'latam' }, - { label: 'Southeast Asia (SG)', value: 'sg' }, - { label: 'Europe (EU)', value: 'eu' }, - ], - required: true, - }, + { name: 'freeFireUid', label: 'Free Fire Player UID', placeholder: 'e.g. 123456789', required: true }, ], }, - pubg: { - id: 'pubg', - name: 'PUBG (PC / Steam)', - icon: '🪖', - color: '#F59E0B', - brandColor: '#F59E0B', - bgGradient: 'from-amber-950/60 via-zinc-950/40 to-black', - borderColor: 'border-amber-500/40', - description: 'Sync PUBG Steam K/D Ratio, Win Rate, Lifetime Kills & Matches', + smashkarts: { + id: 'smashkarts', + name: 'Smash Karts', + icon: '🏎️', + color: '#38BDF8', + brandColor: '#38BDF8', + bgGradient: 'from-sky-950/60 via-slate-950/40 to-black', + borderColor: 'border-sky-500/40', + description: 'Community multiplayer kart battles — direct web party links', fields: [ - { - name: 'playerName', - label: 'PUBG Steam In-Game Name', - placeholder: 'e.g. TGLTN', - required: true, - }, - { - name: 'platform', - label: 'Platform', - placeholder: 'Steam (PC)', - type: 'select', - options: [ - { label: 'Steam (PC)', value: 'steam' }, - ], - required: true, - }, + { name: 'username', label: 'Smash Karts In-Game Username', placeholder: 'e.g. KartKing', required: true }, ], }, - bgmi: { - id: 'bgmi', - name: 'BGMI / PUBG Mobile', - icon: '📱', + codm: { + id: 'codm', + name: 'Call of Duty: Mobile', + icon: '🎯', + color: '#10B981', + brandColor: '#10B981', + bgGradient: 'from-emerald-950/60 via-slate-950/40 to-black', + borderColor: 'border-emerald-500/40', + description: 'Sync CODM Username, MP/BR mode preferences & custom rooms', + fields: [ + { name: 'username', label: 'CODM In-Game Username', placeholder: 'e.g. Ghost_007', required: true }, + ], + }, + roblox: { + id: 'roblox', + name: 'Roblox', + icon: '🧱', + color: '#EF4444', + brandColor: '#EF4444', + bgGradient: 'from-red-950/60 via-slate-950/40 to-black', + borderColor: 'border-red-500/40', + description: 'Roblox party play & private server join link handoff', + fields: [ + { name: 'username', label: 'Roblox Username', placeholder: 'e.g. BloxMaster', required: true }, + ], + }, + minecraft: { + id: 'minecraft', + name: 'Minecraft', + icon: '⛏️', + color: '#22C55E', + brandColor: '#22C55E', + bgGradient: 'from-green-950/60 via-slate-950/40 to-black', + borderColor: 'border-green-500/40', + description: 'Server IP:Port & Realm code manual handoff for multiplayer survival', + fields: [ + { name: 'username', label: 'Minecraft GamerTag', placeholder: 'e.g. CrafterPro', required: true }, + ], + }, + ludoking: { + id: 'ludoking', + name: 'Ludo King', + icon: '🎲', color: '#EAB308', - brandColor: '#F59E0B', - bgGradient: 'from-[#1A1408] via-zinc-950 to-black', - borderColor: 'border-amber-500/40', - description: 'Sync Conqueror Tier, K/D Ratio, Chicken Dinners & Accuracy', + brandColor: '#EAB308', + bgGradient: 'from-yellow-950/60 via-slate-950/40 to-black', + borderColor: 'border-yellow-500/40', + description: '8-digit Private Room Code sharing for 2P/4P Ludo battles', fields: [ - { - name: 'uid', - label: 'BGMI Character ID', - placeholder: 'e.g. 5123456789', - required: true, - }, + { name: 'username', label: 'Ludo King Name', placeholder: 'e.g. LudoChamp', required: true }, ], }, - clashroyale: { - id: 'clashroyale', - name: 'Clash Royale', - icon: '👑', + eightballpool: { + id: 'eightballpool', + name: '8 Ball Pool', + icon: '🎱', color: '#3B82F6', brandColor: '#3B82F6', bgGradient: 'from-blue-950/60 via-slate-950/40 to-black', borderColor: 'border-blue-500/40', - description: 'Sync King Level, Trophies, Favorite Deck & Win Rate', + description: 'Unique ID search for 1v1 pool match challenges', fields: [ - { - name: 'playerTag', - label: 'Player Tag', - placeholder: 'e.g. #2PP820CG', - required: true, - }, + { name: 'uniqueId', label: '8 Ball Pool Unique ID', placeholder: 'e.g. 123-456-789-0', required: true }, ], }, brawlstars: { @@ -194,12 +160,57 @@ export const GAMES_CONFIG: Record = { borderColor: 'border-pink-500/40', description: 'Sync Trophy Count, Highest Trophies, Club & Brawlers', fields: [ - { - name: 'playerTag', - label: 'Player Tag', - placeholder: 'e.g. #90UJLY2', - required: true, - }, + { name: 'playerTag', label: 'Player Tag', placeholder: 'e.g. #90UJLY2', required: true }, ], }, + clashroyale: { + id: 'clashroyale', + name: 'Clash Royale', + icon: '👑', + color: '#3B82F6', + brandColor: '#3B82F6', + bgGradient: 'from-blue-950/60 via-slate-950/40 to-black', + borderColor: 'border-blue-500/40', + description: 'Sync King Level, Trophies, Favorite Deck & Win Rate', + fields: [ + { name: 'playerTag', label: 'Player Tag', placeholder: 'e.g. #2PP820CG', required: true }, + ], + }, + realcricket: { + id: 'realcricket', + name: 'Real Cricket', + icon: '🏏', + color: '#8B5CF6', + brandColor: '#8B5CF6', + bgGradient: 'from-purple-950/60 via-slate-950/40 to-black', + borderColor: 'border-purple-500/40', + description: 'Multiplayer private room ID sharing for 2 Over / 5 Over cricket matches', + fields: [ + { name: 'username', label: 'Real Cricket Profile Name', placeholder: 'e.g. CricketFanatic', required: true }, + ], + }, + efootball: { + id: 'efootball', + name: 'eFootball', + icon: '⚽', + color: '#06B6D4', + brandColor: '#06B6D4', + bgGradient: 'from-cyan-950/60 via-slate-950/40 to-black', + borderColor: 'border-cyan-500/40', + description: 'Match Room Number sharing for 1v1 & Co-op 3v3 football matches', + fields: [ + { name: 'ownerName', label: 'eFootball Owner Name', placeholder: 'e.g. FC_Striker', required: true }, + ], + }, + drdriving: { + id: 'drdriving', + name: 'Dr. Driving', + icon: '🚗', + color: '#64748B', + brandColor: '#64748B', + bgGradient: 'from-slate-900 via-zinc-950 to-black', + borderColor: 'border-slate-500/40', + description: 'Single-player career title. Track personal high scores (multiplayer join unsupported).', + fields: [], + }, }; diff --git a/web/src/config/gamesRegistry.ts b/web/src/config/gamesRegistry.ts new file mode 100644 index 0000000..c39b268 --- /dev/null +++ b/web/src/config/gamesRegistry.ts @@ -0,0 +1,365 @@ +export type IdentityType = + | 'CHARACTER_ID' + | 'PLAYER_TAG' + | 'RIOT_ID' + | 'STEAM_ID64' + | 'USERNAME' + | 'SELF_REPORTED_UID' + | 'NONE'; + +export type TeamModel = 'SOLO' | 'DUO' | 'SQUAD' | 'CUSTOM'; + +export type SessionModel = + | 'DIRECT_LOBBY' + | 'ROOM_CODE' + | 'FRIEND_INVITE' + | 'PARTY_LINK' + | 'EXTERNAL_HANDOFF' + | 'OFFLINE_SOLO'; + +export type JoinCapability = + | 'NATIVE_LINK' + | 'ROOM_CODE' + | 'MANUAL_HANDOFF' + | 'UNSUPPORTED'; + +export type VerificationCapability = 'OFFICIAL_API' | 'SELF_REPORTED' | 'UNAVAILABLE'; + +export type ApiStatus = 'LIVE' | 'COMMUNITY' | 'COMMUNITY_MANUAL' | 'OFFICIAL_API'; + +export interface GameRegistryEntry { + gameId: string; + slug: string; + displayName: string; + category: 'Battle Royale' | 'Strategy' | 'Arcade' | 'FPS' | 'Sandbox' | 'Board' | 'Sports' | 'Racing'; + platforms: string[]; + modes: string[]; + identityType: IdentityType; + requiredProfileFields: string[]; + optionalProfileFields: string[]; + teamModel: TeamModel; + sessionModel: SessionModel; + joinCapability: JoinCapability; + externalLaunchRequirement: boolean; + externalLaunchNote?: string; + verificationCapability: VerificationCapability; + apiStatus: ApiStatus; + enabledState: boolean; + capabilityValidationTimestamp: string; +} + +const TIMESTAMP = new Date().toISOString(); + +export const CANONICAL_GAMES_REGISTRY: Record = { + bgmi: { + gameId: 'bgmi', + slug: 'bgmi', + displayName: 'BGMI', + category: 'Battle Royale', + platforms: ['Android', 'iOS'], + modes: ['Erangel Squad', 'Livik Duo', 'TDM 4v4', 'Classic Solo'], + identityType: 'CHARACTER_ID', + requiredProfileFields: ['uid'], + optionalProfileFields: ['inGameName', 'rank', 'kd'], + teamModel: 'SQUAD', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Copy room code & password to join custom room inside BGMI.', + verificationCapability: 'SELF_REPORTED', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + clashofclans: { + gameId: 'clashofclans', + slug: 'clashofclans', + displayName: 'Clash of Clans', + category: 'Strategy', + platforms: ['Android', 'iOS'], + modes: ['Clan Wars', 'Friendly Challenge', 'Builder Hall', 'Trophy Push'], + identityType: 'PLAYER_TAG', + requiredProfileFields: ['playerTag'], + optionalProfileFields: ['townHallLevel', 'clanName'], + teamModel: 'CUSTOM', + sessionModel: 'FRIEND_INVITE', + joinCapability: 'MANUAL_HANDOFF', + externalLaunchRequirement: true, + externalLaunchNote: 'Use Player Tag to send friendly challenge or clan invite inside Clash of Clans.', + verificationCapability: 'OFFICIAL_API', + apiStatus: 'OFFICIAL_API', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + pubg_mobile: { + gameId: 'pubg_mobile', + slug: 'pubg_mobile', + displayName: 'PUBG Mobile', + category: 'Battle Royale', + platforms: ['Android', 'iOS'], + modes: ['Classic Squad', 'TDM', 'Arena', 'Payload'], + identityType: 'CHARACTER_ID', + requiredProfileFields: ['uid'], + optionalProfileFields: ['inGameName', 'region'], + teamModel: 'SQUAD', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Separate global build from BGMI. Copy custom room ID & password to join.', + verificationCapability: 'SELF_REPORTED', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + freefire: { + gameId: 'freefire', + slug: 'freefire', + displayName: 'Free Fire MAX', + category: 'Battle Royale', + platforms: ['Android', 'iOS'], + modes: ['BR Squad', 'CS Squad', 'Lone Wolf', 'Custom Room'], + identityType: 'SELF_REPORTED_UID', + requiredProfileFields: ['freeFireUid'], + optionalProfileFields: ['freeFireUsername', 'preferredMode', 'rank', 'playstyle', 'micPreference'], + teamModel: 'SQUAD', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Copy Team Code or Room ID to join match in Free Fire MAX.', + verificationCapability: 'SELF_REPORTED', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + smashkarts: { + gameId: 'smashkarts', + slug: 'smashkarts', + displayName: 'Smash Karts', + category: 'Arcade', + platforms: ['Web', 'Mobile Browser'], + modes: ['Free For All', 'Gem Grab', 'Hat Hold', 'Custom Game'], + identityType: 'USERNAME', + requiredProfileFields: ['username'], + optionalProfileFields: ['favoriteKart'], + teamModel: 'SOLO', + sessionModel: 'PARTY_LINK', + joinCapability: 'NATIVE_LINK', + externalLaunchRequirement: false, + externalLaunchNote: 'Direct web party room link provided for immediate lobby joining.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + + codm: { + gameId: 'codm', + slug: 'codm', + displayName: 'Call of Duty: Mobile', + category: 'FPS', + platforms: ['Android', 'iOS'], + modes: ['MP 5v5', 'BR Squad', 'Search & Destroy', 'Private Room'], + identityType: 'USERNAME', + requiredProfileFields: ['username'], + optionalProfileFields: ['playerUid'], + teamModel: 'SQUAD', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Add friend via CODM Username or enter Private Room Code.', + verificationCapability: 'SELF_REPORTED', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + roblox: { + gameId: 'roblox', + slug: 'roblox', + displayName: 'Roblox', + category: 'Arcade', + platforms: ['Android', 'iOS', 'PC', 'Xbox'], + modes: ['Private Server', 'VIP Server', 'Party Play'], + identityType: 'USERNAME', + requiredProfileFields: ['username'], + optionalProfileFields: ['robloxUserId'], + teamModel: 'CUSTOM', + sessionModel: 'PARTY_LINK', + joinCapability: 'NATIVE_LINK', + externalLaunchRequirement: true, + externalLaunchNote: 'Click private server link or join user via Roblox profile.', + verificationCapability: 'SELF_REPORTED', + apiStatus: 'COMMUNITY', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + minecraft: { + gameId: 'minecraft', + slug: 'minecraft', + displayName: 'Minecraft', + category: 'Sandbox', + platforms: ['PC', 'Android', 'iOS', 'Xbox', 'PlayStation'], + modes: ['SMP Server', 'BedWars', 'Survival Co-op', 'Creative Build'], + identityType: 'USERNAME', + requiredProfileFields: ['username'], + optionalProfileFields: ['edition'], + teamModel: 'CUSTOM', + sessionModel: 'EXTERNAL_HANDOFF', + joinCapability: 'MANUAL_HANDOFF', + externalLaunchRequirement: true, + externalLaunchNote: 'Copy Server IP:Port or Realm invite code into Minecraft.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + ludoking: { + gameId: 'ludoking', + slug: 'ludoking', + displayName: 'Ludo King', + category: 'Board', + platforms: ['Android', 'iOS'], + modes: ['2 Player Classic', '4 Player Classic', 'Rush Mode', 'Private Room'], + identityType: 'SELF_REPORTED_UID', + requiredProfileFields: ['username'], + optionalProfileFields: ['level'], + teamModel: 'DUO', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Enter 8-digit Private Room Code in Ludo King Play with Friends mode.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + eightballpool: { + gameId: 'eightballpool', + slug: 'eightballpool', + displayName: '8 Ball Pool', + category: 'Board', + platforms: ['Android', 'iOS', 'Web'], + modes: ['1v1 Match', '9 Ball', 'Private Challenge'], + identityType: 'SELF_REPORTED_UID', + requiredProfileFields: ['uniqueId'], + optionalProfileFields: ['cueName'], + teamModel: 'SOLO', + sessionModel: 'FRIEND_INVITE', + joinCapability: 'MANUAL_HANDOFF', + externalLaunchRequirement: true, + externalLaunchNote: 'Search Unique ID in 8 Ball Pool friends list to send match challenge.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + brawlstars: { + gameId: 'brawlstars', + slug: 'brawlstars', + displayName: 'Brawl Stars', + category: 'Arcade', + platforms: ['Android', 'iOS'], + modes: ['Gem Grab', 'Showdown', 'Brawl Ball', 'Team Room'], + identityType: 'PLAYER_TAG', + requiredProfileFields: ['playerTag'], + optionalProfileFields: ['trophies'], + teamModel: 'SQUAD', + sessionModel: 'PARTY_LINK', + joinCapability: 'MANUAL_HANDOFF', + externalLaunchRequirement: true, + externalLaunchNote: 'Use Team Code or Supercell Friend Link to join room in Brawl Stars.', + verificationCapability: 'OFFICIAL_API', + apiStatus: 'OFFICIAL_API', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + clashroyale: { + gameId: 'clashroyale', + slug: 'clashroyale', + displayName: 'Clash Royale', + category: 'Strategy', + platforms: ['Android', 'iOS'], + modes: ['1v1 Battle', '2v2 Battle', 'Friendly Battle', 'Tournament'], + identityType: 'PLAYER_TAG', + requiredProfileFields: ['playerTag'], + optionalProfileFields: ['trophies'], + teamModel: 'DUO', + sessionModel: 'FRIEND_INVITE', + joinCapability: 'MANUAL_HANDOFF', + externalLaunchRequirement: true, + externalLaunchNote: 'Use Player Tag or Friendly Battle link in Clash Royale.', + verificationCapability: 'OFFICIAL_API', + apiStatus: 'OFFICIAL_API', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + realcricket: { + gameId: 'realcricket', + slug: 'realcricket', + displayName: 'Real Cricket', + category: 'Sports', + platforms: ['Android', 'iOS'], + modes: ['2 Over Match', '5 Over Match', 'Multiplayer Private Room'], + identityType: 'USERNAME', + requiredProfileFields: ['username'], + optionalProfileFields: ['level'], + teamModel: 'SOLO', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Copy Room ID to join Private Room in Real Cricket.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + efootball: { + gameId: 'efootball', + slug: 'efootball', + displayName: 'eFootball', + category: 'Sports', + platforms: ['Android', 'iOS', 'PC', 'Console'], + modes: ['Match Room 1v1', 'Co-op 3v3', 'Friend Match'], + identityType: 'USERNAME', + requiredProfileFields: ['ownerName'], + optionalProfileFields: ['userUniqueId'], + teamModel: 'SOLO', + sessionModel: 'ROOM_CODE', + joinCapability: 'ROOM_CODE', + externalLaunchRequirement: true, + externalLaunchNote: 'Copy Match Room Number to join 1v1 or Co-op room in eFootball.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY_MANUAL', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, + drdriving: { + gameId: 'drdriving', + slug: 'drdriving', + displayName: 'Dr. Driving', + category: 'Racing', + platforms: ['Android', 'iOS'], + modes: ['Offline Career', 'Single Player High Score'], + identityType: 'NONE', + requiredProfileFields: [], + optionalProfileFields: ['highScore'], + teamModel: 'SOLO', + sessionModel: 'OFFLINE_SOLO', + joinCapability: 'UNSUPPORTED', + externalLaunchRequirement: false, + externalLaunchNote: 'Dr. Driving is a single-player driving game. Multiplayer session joining is not supported.', + verificationCapability: 'UNAVAILABLE', + apiStatus: 'COMMUNITY', + enabledState: true, + capabilityValidationTimestamp: TIMESTAMP, + }, +}; + +export function getCanonicalGame(gameId: string): GameRegistryEntry | null { + if (!gameId) return null; + const normalized = gameId.toLowerCase().trim(); + return CANONICAL_GAMES_REGISTRY[normalized] || null; +} + +export function getAllCanonicalGames(): GameRegistryEntry[] { + return Object.values(CANONICAL_GAMES_REGISTRY); +}