All checks were successful
Build / build (push) Successful in 1m45s
- Add pagination support to CloudLeaderboardService with offset parameter - Implement infinite scroll in Leaderboard.svelte using IntersectionObserver - Update seed script to use actual game scoring formulas (time, accuracy, fuel, hull multipliers) - Add level-specific asteroid counts and par times to seed data - Create BUGS.md to track known issues - Partial work on XR camera orientation (documented in BUGS.md) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
/**
|
||
* 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 with actual config values
|
||
const LEVELS = [
|
||
{
|
||
id: 'rookie-training',
|
||
name: 'Rookie Training',
|
||
difficulty: 'recruit',
|
||
asteroids: 5,
|
||
parTime: 120 // 2 minutes expected
|
||
},
|
||
{
|
||
id: 'asteroid-mania',
|
||
name: 'Asteroid Mania!!!',
|
||
difficulty: 'pilot',
|
||
asteroids: 12,
|
||
parTime: 180 // 3 minutes expected (more asteroids, farther away)
|
||
},
|
||
];
|
||
|
||
// 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();
|
||
}
|
||
|
||
/**
|
||
* Calculate score using the actual game's scoring formula
|
||
* Base: 10,000 × timeMultiplier × accuracyMultiplier × fuelMultiplier × hullMultiplier
|
||
*/
|
||
function calculateScore(
|
||
gameTime: number,
|
||
parTime: number,
|
||
accuracy: number,
|
||
fuelConsumed: number,
|
||
hullDamage: number
|
||
): number {
|
||
const BASE_SCORE = 10000;
|
||
|
||
// Time multiplier: exponential decay from par time (0.1x to 3.0x)
|
||
const timeRatio = gameTime / parTime;
|
||
const timeMultiplier = Math.min(3.0, Math.max(0.1, Math.exp(-timeRatio + 1) * 2));
|
||
|
||
// Accuracy multiplier: 1.0x to 2.0x
|
||
const accuracyMultiplier = 1.0 + (accuracy / 100);
|
||
|
||
// Fuel efficiency multiplier: 0.5x to 2.0x
|
||
const fuelMultiplier = Math.max(0.5, 1.0 + ((100 - fuelConsumed) / 100));
|
||
|
||
// Hull integrity multiplier: 0.5x to 2.0x
|
||
const hullMultiplier = Math.max(0.5, 1.0 + ((100 - hullDamage) / 100));
|
||
|
||
return Math.floor(BASE_SCORE * timeMultiplier * accuracyMultiplier * fuelMultiplier * hullMultiplier);
|
||
}
|
||
|
||
/**
|
||
* Calculate star rating using the actual game's star system (0-12 total)
|
||
*/
|
||
function calculateStars(
|
||
gameTime: number,
|
||
parTime: number,
|
||
accuracy: number,
|
||
fuelConsumed: number,
|
||
hullDamage: number
|
||
): number {
|
||
const timeRatio = gameTime / parTime;
|
||
|
||
// Time stars (3 = ≤50% par, 2 = ≤100%, 1 = ≤150%, 0 = >150%)
|
||
const timeStars = timeRatio <= 0.5 ? 3 : timeRatio <= 1.0 ? 2 : timeRatio <= 1.5 ? 1 : 0;
|
||
|
||
// Accuracy stars (3 = ≥75%, 2 = ≥50%, 1 = ≥25%, 0 = <25%)
|
||
const accuracyStars = accuracy >= 75 ? 3 : accuracy >= 50 ? 2 : accuracy >= 25 ? 1 : 0;
|
||
|
||
// Fuel stars (3 = ≤30%, 2 = ≤60%, 1 = ≤80%, 0 = >80%)
|
||
const fuelStars = fuelConsumed <= 30 ? 3 : fuelConsumed <= 60 ? 2 : fuelConsumed <= 80 ? 1 : 0;
|
||
|
||
// Hull stars (3 = ≤10%, 2 = ≤30%, 1 = ≤60%, 0 = >60%)
|
||
const hullStars = hullDamage <= 10 ? 3 : hullDamage <= 30 ? 2 : hullDamage <= 60 ? 1 : 0;
|
||
|
||
return timeStars + accuracyStars + fuelStars + hullStars;
|
||
}
|
||
|
||
function generateFakeEntry() {
|
||
const level = LEVELS[randomInt(0, LEVELS.length - 1)];
|
||
const endReasonObj = weightedRandom(END_REASONS);
|
||
const completed = endReasonObj.reason === 'victory';
|
||
|
||
// Use level-specific asteroid count
|
||
const totalAsteroids = level.asteroids;
|
||
const asteroidsDestroyed = completed
|
||
? totalAsteroids
|
||
: randomInt(Math.floor(totalAsteroids * 0.3), totalAsteroids - 1);
|
||
|
||
// Generate realistic stats based on 2-5 minute gameplay
|
||
let gameTimeSeconds: number;
|
||
let accuracy: number;
|
||
let hullDamageTaken: number;
|
||
let fuelConsumed: number;
|
||
|
||
if (completed) {
|
||
// Victory: 2-5 minutes, decent stats
|
||
gameTimeSeconds = randomInt(level.parTime * 0.8, level.parTime * 2.5); // 80% to 250% of par
|
||
accuracy = randomFloat(45, 85); // Most players hit 45-85%
|
||
hullDamageTaken = randomFloat(5, 55); // Some damage but survived
|
||
fuelConsumed = randomFloat(25, 70); // Used fuel but made it back
|
||
} else if (endReasonObj.reason === 'death') {
|
||
// Death: Usually faster (died before completing), worse stats
|
||
gameTimeSeconds = randomInt(level.parTime * 0.5, level.parTime * 1.5);
|
||
accuracy = randomFloat(25, 60); // Struggled with aim
|
||
hullDamageTaken = randomFloat(80, 100); // Took fatal damage
|
||
fuelConsumed = randomFloat(30, 80); // Died before fuel was an issue
|
||
} else {
|
||
// Stranded: Ran out of fuel far from base
|
||
gameTimeSeconds = randomInt(level.parTime * 1.5, level.parTime * 3);
|
||
accuracy = randomFloat(35, 70); // Okay aim
|
||
hullDamageTaken = randomFloat(20, 60); // Some damage
|
||
fuelConsumed = randomFloat(95, 100); // Ran out of fuel!
|
||
}
|
||
|
||
// Calculate score and stars using actual game formulas
|
||
const finalScore = completed
|
||
? calculateScore(gameTimeSeconds, level.parTime, accuracy, fuelConsumed, hullDamageTaken)
|
||
: Math.floor(calculateScore(gameTimeSeconds, level.parTime, accuracy, fuelConsumed, hullDamageTaken) * 0.3); // 30% penalty for not completing
|
||
|
||
const starRating = calculateStars(gameTimeSeconds, level.parTime, accuracy, fuelConsumed, hullDamageTaken);
|
||
|
||
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);
|
||
});
|