Add leaderboard seed script with auto-migration
All checks were successful
Build / build (push) Successful in 1m43s

- Create scripts/seedLeaderboard.ts to populate fake leaderboard data
- Automatically creates is_test_data column if missing
- Add npm scripts: seed:leaderboard and seed:leaderboard:clean
- Add postgres and dotenv dev dependencies
- Configurable entry count (default 250)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-26 10:25:51 -06:00
parent a3e17c95db
commit e5607a564f
4 changed files with 322 additions and 1 deletions

29
package-lock.json generated
View File

@ -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",

View File

@ -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",

287
scripts/seedLeaderboard.ts Normal file
View File

@ -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<void> {
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<T extends { weight: number }>(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);
});

View File

@ -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
}
/**