space-game/scripts/seedLeaderboard.ts
Michael Mainguy a9070a5d8f
All checks were successful
Build / build (push) Successful in 1m45s
Add leaderboard infinite scroll and improve seed script scoring
- 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>
2025-11-26 12:51:43 -06:00

356 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});