Add leaderboard seed script with auto-migration
All checks were successful
Build / build (push) Successful in 1m43s
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:
parent
a3e17c95db
commit
e5607a564f
29
package-lock.json
generated
29
package-lock.json
generated
@ -26,6 +26,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"postgres": "^3.4.4",
|
||||||
"svelte": "^5.43.14",
|
"svelte": "^5.43.14",
|
||||||
"svelte-preprocess": "^6.0.3",
|
"svelte-preprocess": "^6.0.3",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
@ -1332,6 +1334,19 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/dpop": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz",
|
||||||
@ -1707,6 +1722,20 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
|||||||
@ -12,7 +12,9 @@
|
|||||||
"speech": "tsc && node ./dist/server/voices.js",
|
"speech": "tsc && node ./dist/server/voices.js",
|
||||||
"export-blend": "tsx scripts/exportBlend.ts",
|
"export-blend": "tsx scripts/exportBlend.ts",
|
||||||
"export-blend:watch": "tsx scripts/exportBlend.ts --watch",
|
"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": {
|
"dependencies": {
|
||||||
"@auth0/auth0-spa-js": "^2.8.0",
|
"@auth0/auth0-spa-js": "^2.8.0",
|
||||||
@ -33,6 +35,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"postgres": "^3.4.4",
|
||||||
"svelte": "^5.43.14",
|
"svelte": "^5.43.14",
|
||||||
"svelte-preprocess": "^6.0.3",
|
"svelte-preprocess": "^6.0.3",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
|
|||||||
287
scripts/seedLeaderboard.ts
Normal file
287
scripts/seedLeaderboard.ts
Normal 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);
|
||||||
|
});
|
||||||
@ -22,6 +22,7 @@ export interface CloudLeaderboardEntry {
|
|||||||
final_score: number;
|
final_score: number;
|
||||||
star_rating: number;
|
star_rating: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
is_test_data?: boolean; // Flag for seed/test data - allows cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user