diff --git a/package-lock.json b/package-lock.json index 82a9f45..b99b466 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,8 @@ "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", "@types/node": "^20.0.0", + "dotenv": "^16.3.1", + "postgres": "^3.4.4", "svelte": "^5.43.14", "svelte-preprocess": "^6.0.3", "tsx": "^4.7.1", @@ -1332,6 +1334,19 @@ "node": ">=0.4.0" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dpop": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", @@ -1707,6 +1722,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", diff --git a/package.json b/package.json index fb954d5..2fc7a40 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "speech": "tsc && node ./dist/server/voices.js", "export-blend": "tsx scripts/exportBlend.ts", "export-blend:watch": "tsx scripts/exportBlend.ts --watch", - "export-blend:batch": "tsx scripts/exportBlend.ts --batch" + "export-blend:batch": "tsx scripts/exportBlend.ts --batch", + "seed:leaderboard": "tsx scripts/seedLeaderboard.ts", + "seed:leaderboard:clean": "tsx scripts/seedLeaderboard.ts --clean" }, "dependencies": { "@auth0/auth0-spa-js": "^2.8.0", @@ -33,6 +35,8 @@ "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", "@types/node": "^20.0.0", + "dotenv": "^16.3.1", + "postgres": "^3.4.4", "svelte": "^5.43.14", "svelte-preprocess": "^6.0.3", "tsx": "^4.7.1", diff --git a/scripts/seedLeaderboard.ts b/scripts/seedLeaderboard.ts new file mode 100644 index 0000000..db3ef30 --- /dev/null +++ b/scripts/seedLeaderboard.ts @@ -0,0 +1,287 @@ +/** + * Seed script for populating leaderboard with fake test data + * + * Usage: + * npm run seed:leaderboard # Insert 250 fake entries + * npm run seed:leaderboard -- --count=50 # Insert 50 fake entries + * npm run seed:leaderboard:clean # Delete all test data + * + * Required .env variables: + * VITE_SUPABASE_PROJECT - Supabase project URL + * SUPABASE_SERVICE_KEY - Service role key (Settings → API) + * SUPABASE_DB_URL - Direct DB connection string (Settings → Database → URI) + * + * The script will automatically create the is_test_data column if it doesn't exist. + */ + +import { createClient } from '@supabase/supabase-js'; +import postgres from 'postgres'; +import * as dotenv from 'dotenv'; + +// Load environment variables +dotenv.config(); + +const SUPABASE_URL = process.env.VITE_SUPABASE_PROJECT; +const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY; +const DATABASE_URL = process.env.SUPABASE_DB_URL; + +if (!SUPABASE_URL || !SUPABASE_SERVICE_KEY) { + console.error('Missing required environment variables:'); + if (!SUPABASE_URL) console.error(' - VITE_SUPABASE_PROJECT'); + if (!SUPABASE_SERVICE_KEY) console.error(' - SUPABASE_SERVICE_KEY'); + console.error('\nMake sure these are set in your .env file'); + process.exit(1); +} + +if (!DATABASE_URL) { + console.error('Missing SUPABASE_DB_URL environment variable.'); + console.error('Get it from Supabase → Settings → Database → Connection string (URI)'); + console.error('Format: postgresql://postgres:[password]@[host]:5432/postgres'); + process.exit(1); +} + +// Create Supabase client with service key (bypasses RLS) +const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY); + +// Create direct Postgres connection for DDL operations +const sql = postgres(DATABASE_URL); + +/** + * Check if is_test_data column exists, create it if not + */ +async function ensureTestDataColumn(): Promise { + console.log('Checking for is_test_data column...'); + + // Check if column exists using information_schema + const result = await sql` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'leaderboard' AND column_name = 'is_test_data' + ) as exists + `; + + if (result[0]?.exists) { + console.log(' Column exists ✓'); + return; + } + + console.log(' Column missing - creating...'); + + // Add the column + await sql` + ALTER TABLE leaderboard + ADD COLUMN is_test_data BOOLEAN NOT NULL DEFAULT false + `; + + // Create partial index for efficient cleanup queries + await sql` + CREATE INDEX IF NOT EXISTS idx_leaderboard_test_data + ON leaderboard(is_test_data) + WHERE is_test_data = true + `; + + console.log(' Column and index created ✓'); +} + +// Levels from directory.json +const LEVELS = [ + { id: 'rookie-training', name: 'Rookie Training', difficulty: 'recruit' }, + { id: 'asteroid-mania', name: 'Asteroid Mania!!!', difficulty: 'recruit' }, +]; + +// Pool of realistic player names +const PLAYER_NAMES = [ + 'AceOfSpace', 'StarPilot_X', 'CosmicCrusader', 'VoidWalker', 'NebulaNinja', + 'AstroAce', 'GalaxyGlider', 'SpaceRaider', 'OrbitRunner', 'CometChaser', + 'MeteorMaster', 'PulsarPilot', 'QuasarQueen', 'StellarStrike', 'NovaNavigator', + 'DarkMatterDan', 'EventHorizon', 'BlackHoleBob', 'WarpDriveWill', 'LightSpeedLou', + 'RocketRider', 'JetStreamJane', 'ThrusterTom', 'BoosterBeth', 'FuelCellFred', + 'ShieldMaster', 'LaserLarry', 'PlasmaPatty', 'PhotonPhil', 'IonIvy', + 'ZeroGravZach', 'FloatingFrank', 'DriftingDiana', 'WeightlessWendy', 'FreeFloater', + 'CommanderCole', 'CaptainKira', 'LtLunar', 'EnsignElla', 'AdmiralAlex', + 'TheDestroyer', 'RockBreaker', 'AsteroidAnnie', 'BoulderBuster', 'StoneSlayer', + 'SpeedDemon', 'QuickShot', 'FastFingers', 'RapidReflexes', 'SwiftStrike' +]; + +// End reasons with weights +const END_REASONS = [ + { reason: 'victory', weight: 70 }, + { reason: 'death', weight: 20 }, + { reason: 'stranded', weight: 10 }, +]; + +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function randomFloat(min: number, max: number, decimals: number = 2): number { + const value = Math.random() * (max - min) + min; + return parseFloat(value.toFixed(decimals)); +} + +function weightedRandom(items: T[]): T { + const totalWeight = items.reduce((sum, item) => sum + item.weight, 0); + let random = Math.random() * totalWeight; + + for (const item of items) { + random -= item.weight; + if (random <= 0) return item; + } + return items[items.length - 1]; +} + +function randomDate(daysBack: number): string { + const now = new Date(); + const pastDate = new Date(now.getTime() - Math.random() * daysBack * 24 * 60 * 60 * 1000); + return pastDate.toISOString(); +} + +function generateFakeEntry() { + const level = LEVELS[randomInt(0, LEVELS.length - 1)]; + const endReasonObj = weightedRandom(END_REASONS); + const completed = endReasonObj.reason === 'victory'; + + // Harder levels tend to have lower scores + const difficultyMultiplier = { + 'recruit': 1.0, + 'pilot': 0.9, + 'captain': 0.8, + 'commander': 0.7, + }[level.difficulty] || 0.8; + + // Generate stats + const totalAsteroids = randomInt(5, 50); + const asteroidsDestroyed = completed + ? totalAsteroids + : randomInt(Math.floor(totalAsteroids * 0.2), totalAsteroids - 1); + + const accuracy = completed + ? randomFloat(50, 95) + : randomFloat(30, 70); + + const gameTimeSeconds = randomInt(60, 300); + const hullDamageTaken = completed + ? randomFloat(0, 60) + : randomFloat(40, 100); + + const fuelConsumed = completed + ? randomFloat(20, 80) + : randomFloat(50, 100); + + // Calculate score (simplified version) + const baseScore = asteroidsDestroyed * 1000; + const accuracyBonus = Math.floor(accuracy * 10); + const timeBonus = Math.max(0, 300 - gameTimeSeconds); + const survivalBonus = completed ? 500 : 0; + const finalScore = Math.floor((baseScore + accuracyBonus + timeBonus + survivalBonus) * difficultyMultiplier); + + // Star rating based on performance (0-12) + const starRating = completed + ? randomInt(4, 12) + : randomInt(0, 4); + + return { + user_id: `test-data|fake-${randomInt(1000, 9999)}`, + player_name: PLAYER_NAMES[randomInt(0, PLAYER_NAMES.length - 1)], + level_id: level.id, + level_name: level.name, + completed, + end_reason: endReasonObj.reason, + game_time_seconds: gameTimeSeconds, + asteroids_destroyed: asteroidsDestroyed, + total_asteroids: totalAsteroids, + accuracy, + hull_damage_taken: hullDamageTaken, + fuel_consumed: fuelConsumed, + final_score: finalScore, + star_rating: starRating, + created_at: randomDate(30), // Random date in last 30 days + is_test_data: true, + }; +} + +async function seedLeaderboard(count: number) { + // Ensure column exists before seeding + await ensureTestDataColumn(); + + console.log(`\nSeeding leaderboard with ${count} fake entries...`); + + const entries = []; + for (let i = 0; i < count; i++) { + entries.push(generateFakeEntry()); + } + + // Insert in batches of 50 + const batchSize = 50; + let inserted = 0; + + for (let i = 0; i < entries.length; i += batchSize) { + const batch = entries.slice(i, i + batchSize); + + const { data, error } = await supabase + .from('leaderboard') + .insert(batch) + .select('id'); + + if (error) { + console.error(`Error inserting batch ${Math.floor(i / batchSize) + 1}:`, error); + process.exit(1); + } + + inserted += batch.length; + console.log(` Inserted ${inserted}/${count} entries...`); + } + + console.log(`\nSuccessfully inserted ${inserted} fake leaderboard entries!`); + console.log('To clean up, run: npm run seed:leaderboard:clean'); +} + +async function cleanTestData() { + // Ensure column exists before cleaning + await ensureTestDataColumn(); + + console.log('\nDeleting all test data from leaderboard...'); + + const { data, error, count } = await supabase + .from('leaderboard') + .delete() + .eq('is_test_data', true) + .select('id'); + + if (error) { + console.error('Error deleting test data:', error); + process.exit(1); + } + + const deletedCount = data?.length || 0; + console.log(`Successfully deleted ${deletedCount} test entries!`); +} + +// Parse command line args +const args = process.argv.slice(2); +const isClean = args.includes('--clean'); +const countArg = args.find(arg => arg.startsWith('--count=')); +const count = countArg ? parseInt(countArg.split('=')[1], 10) : 250; + +async function main() { + try { + if (isClean) { + await cleanTestData(); + } else { + if (isNaN(count) || count < 1) { + console.error('Invalid count. Usage: npm run seed:leaderboard -- --count=100'); + process.exit(1); + } + await seedLeaderboard(count); + } + } finally { + // Close postgres connection + await sql.end(); + } +} + +main().catch((error) => { + console.error('Script failed:', error); + sql.end(); + process.exit(1); +}); diff --git a/src/services/cloudLeaderboardService.ts b/src/services/cloudLeaderboardService.ts index e343d26..7af32ec 100644 --- a/src/services/cloudLeaderboardService.ts +++ b/src/services/cloudLeaderboardService.ts @@ -22,6 +22,7 @@ export interface CloudLeaderboardEntry { final_score: number; star_rating: number; created_at: string; + is_test_data?: boolean; // Flag for seed/test data - allows cleanup } /**