space-game/src/levels/migration/legacyMigration.ts
Michael Mainguy 244a25fff5
All checks were successful
Build / build (push) Successful in 1m34s
Implement hybrid level storage system with JSON-based defaults and configurable orbit constraints
Major changes:
- Add LevelRegistry for managing default (JSON) and custom (localStorage) levels
- Default levels now load from /public/levels/*.json files
- Add 6 default level JSON files (rookie-training through final-challenge)
- Implement version-based automatic cache invalidation
- Add LevelVersionManager for tracking level updates
- Add LevelStatsManager for performance tracking (completion rate, best time, etc.)
- Add legacy migration tool for existing localStorage data
- Update level selector UI with stats display and version badges
- Add configurable orbit constraints per level (useOrbitConstraints flag)
- Hide copy button in level selector UI (TODO: re-enable later)
- Add extensive debug logging for velocity troubleshooting
- Add cloud sync infrastructure interfaces (future-ready)

Technical improvements:
- Hybrid storage: immutable defaults from JSON, editable custom levels in localStorage
- Automatic cache refresh when directory.json version changes
- Cache API for offline support
- Fresh start migration approach with export option
- Level loading now initializes before router starts

Physics configuration:
- Add useOrbitConstraints flag to LevelConfig
- Rookietraining.json uses constraints (velocities will create orbital motion)
- Debug logging added to verify velocity application

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 18:40:01 -06:00

331 lines
11 KiB
TypeScript

import {LevelConfig} from "../config/levelConfig";
const LEGACY_STORAGE_KEY = 'space-game-levels';
const ARCHIVE_STORAGE_KEY = 'space-game-levels-archive';
const CUSTOM_LEVELS_KEY = 'space-game-custom-levels';
const MIGRATION_STATUS_KEY = 'space-game-migration-status';
/**
* Migration status information
*/
export interface MigrationStatus {
migrated: boolean;
migratedAt?: Date;
version: string;
customLevelsMigrated: number;
defaultLevelsRemoved: number;
}
/**
* Result of migration operation
*/
export interface MigrationResult {
success: boolean;
customLevelsMigrated: number;
defaultLevelsFound: number;
error?: string;
legacyDataArchived: boolean;
}
/**
* Handles migration from legacy localStorage format to new hybrid system
*/
export class LegacyMigration {
private static readonly MIGRATION_VERSION = '2.0';
/**
* Check if migration is needed
*/
public static needsMigration(): boolean {
// Check if migration was already completed
const status = this.getMigrationStatus();
if (status && status.migrated) {
return false;
}
// Check if legacy data exists
const legacyData = localStorage.getItem(LEGACY_STORAGE_KEY);
return legacyData !== null && legacyData.length > 0;
}
/**
* Get current migration status
*/
public static getMigrationStatus(): MigrationStatus | null {
const stored = localStorage.getItem(MIGRATION_STATUS_KEY);
if (!stored) {
return null;
}
try {
const status: MigrationStatus = JSON.parse(stored);
if (status.migratedAt && typeof status.migratedAt === 'string') {
status.migratedAt = new Date(status.migratedAt);
}
return status;
} catch (error) {
console.error('Failed to parse migration status:', error);
return null;
}
}
/**
* Perform the migration
*/
public static migrate(): MigrationResult {
const result: MigrationResult = {
success: false,
customLevelsMigrated: 0,
defaultLevelsFound: 0,
legacyDataArchived: false
};
try {
// Load legacy data
const legacyData = localStorage.getItem(LEGACY_STORAGE_KEY);
if (!legacyData) {
result.error = 'No legacy data found';
return result;
}
const legacyLevels: [string, LevelConfig][] = JSON.parse(legacyData);
// Separate custom from default levels
const customLevels: [string, LevelConfig][] = [];
for (const [name, config] of legacyLevels) {
if (config.metadata?.type === 'default') {
result.defaultLevelsFound++;
// Skip default levels - they'll be loaded from JSON files now
} else {
customLevels.push([name, config]);
result.customLevelsMigrated++;
}
}
// Save custom levels to new storage location
if (customLevels.length > 0) {
localStorage.setItem(CUSTOM_LEVELS_KEY, JSON.stringify(customLevels));
}
// Archive legacy data (don't delete immediately)
this.archiveLegacyData(legacyData);
result.legacyDataArchived = true;
// Clear legacy storage key
localStorage.removeItem(LEGACY_STORAGE_KEY);
// Record migration status
const status: MigrationStatus = {
migrated: true,
migratedAt: new Date(),
version: this.MIGRATION_VERSION,
customLevelsMigrated: result.customLevelsMigrated,
defaultLevelsRemoved: result.defaultLevelsFound
};
localStorage.setItem(MIGRATION_STATUS_KEY, JSON.stringify(status));
result.success = true;
console.log('Migration completed:', result);
} catch (error) {
result.error = error instanceof Error ? error.message : 'Unknown error';
console.error('Migration failed:', error);
}
return result;
}
/**
* Archive legacy data for potential recovery
*/
private static archiveLegacyData(legacyData: string): void {
const archive = {
data: legacyData,
archivedAt: new Date().toISOString(),
migrationVersion: this.MIGRATION_VERSION
};
localStorage.setItem(ARCHIVE_STORAGE_KEY, JSON.stringify(archive));
}
/**
* Get archived legacy data (for export/recovery)
*/
public static getArchivedData(): string | null {
const stored = localStorage.getItem(ARCHIVE_STORAGE_KEY);
if (!stored) {
return null;
}
try {
const archive = JSON.parse(stored);
return archive.data || null;
} catch (error) {
console.error('Failed to parse archived data:', error);
return null;
}
}
/**
* Export legacy data as JSON file
*/
public static exportLegacyData(): string | null {
const archivedData = this.getArchivedData();
if (!archivedData) {
return null;
}
try {
const levels: [string, LevelConfig][] = JSON.parse(archivedData);
const exportData = {
exportedAt: new Date().toISOString(),
migrationVersion: this.MIGRATION_VERSION,
levels: Object.fromEntries(levels)
};
return JSON.stringify(exportData, null, 2);
} catch (error) {
console.error('Failed to export legacy data:', error);
return null;
}
}
/**
* Download legacy data as JSON file
*/
public static downloadLegacyData(): void {
const jsonString = this.exportLegacyData();
if (!jsonString) {
console.warn('No legacy data to download');
return;
}
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `legacy-levels-backup-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Clear archived data (after user confirms backup)
*/
public static clearArchive(): void {
localStorage.removeItem(ARCHIVE_STORAGE_KEY);
}
/**
* Reset migration status (for testing/debugging)
*/
public static resetMigration(): void {
localStorage.removeItem(MIGRATION_STATUS_KEY);
console.log('Migration status reset');
}
/**
* Full reset - clear all migration data (dangerous!)
*/
public static fullReset(): void {
localStorage.removeItem(LEGACY_STORAGE_KEY);
localStorage.removeItem(ARCHIVE_STORAGE_KEY);
localStorage.removeItem(CUSTOM_LEVELS_KEY);
localStorage.removeItem(MIGRATION_STATUS_KEY);
console.log('Full migration reset completed');
}
/**
* Create and show migration modal UI
*/
public static showMigrationModal(onComplete: (result: MigrationResult) => void): void {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
font-family: 'Courier New', monospace;
`;
const content = document.createElement('div');
content.style.cssText = `
background: #1a1a1a;
border: 2px solid #00ff00;
padding: 30px;
max-width: 600px;
color: #00ff00;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.3);
`;
content.innerHTML = `
<h2 style="margin-top: 0; color: #00ff00;">Level System Updated</h2>
<p>The level storage system has been upgraded!</p>
<p><strong>Changes:</strong></p>
<ul>
<li>Default levels now load from game files (always available)</li>
<li>Your custom levels remain in browser storage</li>
<li>Version tracking and update notifications enabled</li>
<li>Level statistics and performance tracking added</li>
</ul>
<p><strong>Your data will be migrated automatically.</strong></p>
<p>A backup of your old level data will be saved.</p>
<div style="margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end;">
<button id="export-backup" style="padding: 10px 20px; background: #004400; color: #00ff00; border: 1px solid #00ff00; cursor: pointer;">
Export Backup
</button>
<button id="migrate-now" style="padding: 10px 20px; background: #006600; color: #00ff00; border: 1px solid #00ff00; cursor: pointer; font-weight: bold;">
Migrate Now
</button>
</div>
<p id="migration-status" style="margin-top: 15px; font-size: 0.9em; color: #ffff00; display: none;"></p>
`;
modal.appendChild(content);
document.body.appendChild(modal);
const exportBtn = content.querySelector('#export-backup') as HTMLButtonElement;
const migrateBtn = content.querySelector('#migrate-now') as HTMLButtonElement;
const statusText = content.querySelector('#migration-status') as HTMLParagraphElement;
exportBtn.addEventListener('click', () => {
this.downloadLegacyData();
statusText.textContent = 'Backup downloaded! You can now proceed with migration.';
statusText.style.display = 'block';
});
migrateBtn.addEventListener('click', () => {
statusText.textContent = 'Migrating...';
statusText.style.display = 'block';
statusText.style.color = '#ffff00';
// Give UI time to update
setTimeout(() => {
const result = this.migrate();
if (result.success) {
statusText.textContent = `Migration complete! ${result.customLevelsMigrated} custom levels migrated.`;
statusText.style.color = '#00ff00';
setTimeout(() => {
document.body.removeChild(modal);
onComplete(result);
}, 2000);
} else {
statusText.textContent = `Migration failed: ${result.error}`;
statusText.style.color = '#ff0000';
}
}, 100);
});
}
}