diff --git a/backend/core/config.js b/backend/core/config.js index 217b860..34b2fc3 100644 --- a/backend/core/config.js +++ b/backend/core/config.js @@ -54,6 +54,7 @@ function getAppDir() { const CONFIG_FILE = path.join(getAppDir(), 'config.json'); const CONFIG_BACKUP = path.join(getAppDir(), 'config.json.bak'); const CONFIG_TEMP = path.join(getAppDir(), 'config.json.tmp'); +const UUID_STORE_FILE = path.join(getAppDir(), 'uuid-store.json'); // ============================================================================= // CONFIG VALIDATION @@ -152,6 +153,22 @@ function saveConfig(update) { // Load current config const currentConfig = loadConfig(); + + // SAFETY: If config file exists on disk but loadConfig() returned empty, + // something is wrong (file locked, corrupted, etc.). Refuse to save + // because merging with {} would wipe all existing data (userUuids, username, etc.) + if (Object.keys(currentConfig).length === 0 && fs.existsSync(CONFIG_FILE)) { + const fileSize = fs.statSync(CONFIG_FILE).size; + if (fileSize > 2) { // More than just "{}" + console.error(`[Config] REFUSING to save — loaded empty but file exists (${fileSize} bytes). Retrying load...`); + // Wait and retry the load + const delay = attempt * 200; + const start = Date.now(); + while (Date.now() - start < delay) { /* busy wait */ } + continue; + } + } + const newConfig = { ...currentConfig, ...update }; const data = JSON.stringify(newConfig, null, 2); @@ -238,11 +255,18 @@ function saveUsername(username) { // Check if we're actually changing the username (case-insensitive comparison) const isRename = currentName && currentName.toLowerCase() !== newName.toLowerCase(); + // Also update UUID store (source of truth) + migrateUuidStoreIfNeeded(); + const uuidStore = loadUuidStore(); + if (isRename) { // Find the UUID for the current username const currentKey = Object.keys(userUuids).find( k => k.toLowerCase() === currentName.toLowerCase() ); + const currentStoreKey = Object.keys(uuidStore).find( + k => k.toLowerCase() === currentName.toLowerCase() + ); if (currentKey && userUuids[currentKey]) { // Check if target username already exists (would be a different identity) @@ -258,6 +282,9 @@ function saveUsername(username) { const uuid = userUuids[currentKey]; delete userUuids[currentKey]; userUuids[newName] = uuid; + // Same in UUID store + if (currentStoreKey) delete uuidStore[currentStoreKey]; + uuidStore[newName] = uuid; console.log(`[Config] Renamed identity: "${currentKey}" → "${newName}" (UUID preserved: ${uuid})`); } } @@ -270,11 +297,20 @@ function saveUsername(username) { const uuid = userUuids[currentKey]; delete userUuids[currentKey]; userUuids[newName] = uuid; + // Same in UUID store + const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === currentName.toLowerCase()); + if (storeKey) { + delete uuidStore[storeKey]; + uuidStore[newName] = uuid; + } console.log(`[Config] Updated username case: "${currentKey}" → "${newName}"`); } } - // Save both username and updated userUuids + // Save UUID store + saveUuidStore(uuidStore); + + // Save both username and updated userUuids to config saveConfig({ username: newName, userUuids }); console.log(`[Config] Username saved: "${newName}"`); return newName; @@ -310,6 +346,7 @@ function hasUsername() { // ============================================================================= // UUID MANAGEMENT - Persistent and safe +// Uses separate uuid-store.json as source of truth (survives config.json corruption) // ============================================================================= /** @@ -320,10 +357,55 @@ function normalizeUsername(username) { return username.trim().toLowerCase(); } +/** + * Load UUID store from separate file (independent of config.json) + */ +function loadUuidStore() { + try { + if (fs.existsSync(UUID_STORE_FILE)) { + const data = fs.readFileSync(UUID_STORE_FILE, 'utf8'); + if (data.trim()) { + return JSON.parse(data); + } + } + } catch (err) { + console.error('[UUID Store] Failed to load:', err.message); + } + return {}; +} + +/** + * Save UUID store to separate file (atomic write) + */ +function saveUuidStore(store) { + try { + const dir = path.dirname(UUID_STORE_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const tmpFile = UUID_STORE_FILE + '.tmp'; + fs.writeFileSync(tmpFile, JSON.stringify(store, null, 2), 'utf8'); + fs.renameSync(tmpFile, UUID_STORE_FILE); + } catch (err) { + console.error('[UUID Store] Failed to save:', err.message); + } +} + +/** + * One-time migration: copy userUuids from config.json to uuid-store.json + */ +function migrateUuidStoreIfNeeded() { + if (fs.existsSync(UUID_STORE_FILE)) return; // Already migrated + const config = loadConfig(); + if (config.userUuids && Object.keys(config.userUuids).length > 0) { + console.log('[UUID Store] Migrating', Object.keys(config.userUuids).length, 'UUIDs from config.json'); + saveUuidStore(config.userUuids); + } +} + /** * Get UUID for a username - * Creates new UUID only if user explicitly doesn't exist - * Uses case-insensitive lookup to prevent duplicates, but preserves original case for display + * Source of truth: uuid-store.json (separate from config.json) + * Also writes to config.json for backward compatibility + * Creates new UUID only if user doesn't exist in EITHER store */ function getUuidForUser(username) { const { v4: uuidv4 } = require('uuid'); @@ -335,32 +417,69 @@ function getUuidForUser(username) { const displayName = username.trim(); const normalizedLookup = displayName.toLowerCase(); - const config = loadConfig(); - const userUuids = config.userUuids || {}; + // Ensure UUID store exists (one-time migration from config.json) + migrateUuidStoreIfNeeded(); - // Case-insensitive lookup - find existing key regardless of case - const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup); + // 1. Check UUID store first (source of truth) + const uuidStore = loadUuidStore(); + const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup); - if (existingKey) { - // Found existing - return UUID, update display name if case changed - const existingUuid = userUuids[existingKey]; + if (storeKey) { + const existingUuid = uuidStore[storeKey]; - // If user typed different case, update the key to new case (preserving UUID) - if (existingKey !== displayName) { - console.log(`[Config] Updating username case: "${existingKey}" → "${displayName}"`); - delete userUuids[existingKey]; - userUuids[displayName] = existingUuid; - saveConfig({ userUuids }); + // Update case if needed + if (storeKey !== displayName) { + console.log(`[UUID Store] Updating username case: "${storeKey}" → "${displayName}"`); + delete uuidStore[storeKey]; + uuidStore[displayName] = existingUuid; + saveUuidStore(uuidStore); } + // Sync to config.json (backward compat, non-critical) + try { + const config = loadConfig(); + const configUuids = config.userUuids || {}; + const configKey = Object.keys(configUuids).find(k => k.toLowerCase() === normalizedLookup); + if (!configKey || configUuids[configKey] !== existingUuid) { + if (configKey) delete configUuids[configKey]; + configUuids[displayName] = existingUuid; + saveConfig({ userUuids: configUuids }); + } + } catch (e) { + // Non-critical — UUID store is the source of truth + } + + console.log(`[UUID] ${displayName} → ${existingUuid} (from uuid-store)`); return existingUuid; } - // Create new UUID for new user - store with original case + // 2. Fallback: check config.json (recovery if uuid-store.json was lost) + const config = loadConfig(); + const userUuids = config.userUuids || {}; + const configKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup); + + if (configKey) { + const recoveredUuid = userUuids[configKey]; + console.warn(`[UUID] RECOVERED "${displayName}" → ${recoveredUuid} from config.json (uuid-store was missing)`); + + // Save to UUID store + uuidStore[displayName] = recoveredUuid; + saveUuidStore(uuidStore); + + return recoveredUuid; + } + + // 3. New user — generate UUID, save to BOTH stores const newUuid = uuidv4(); + console.log(`[UUID] NEW user "${displayName}" → ${newUuid}`); + + // Save to UUID store (source of truth) + uuidStore[displayName] = newUuid; + saveUuidStore(uuidStore); + + // Save to config.json (backward compat) userUuids[displayName] = newUuid; saveConfig({ userUuids }); - console.log(`[Config] Created new UUID for "${displayName}": ${newUuid}`); return newUuid; } @@ -380,22 +499,26 @@ function getCurrentUuid() { * Get all UUID mappings (raw object) */ function getAllUuidMappings() { - const config = loadConfig(); - return config.userUuids || {}; + migrateUuidStoreIfNeeded(); + const uuidStore = loadUuidStore(); + // Fallback to config if uuid-store is empty + if (Object.keys(uuidStore).length === 0) { + const config = loadConfig(); + return config.userUuids || {}; + } + return uuidStore; } /** * Get all UUID mappings as array with current user flag */ function getAllUuidMappingsArray() { - const config = loadConfig(); - const userUuids = config.userUuids || {}; + const allMappings = getAllUuidMappings(); const currentUsername = loadUsername(); - // Case-insensitive comparison for isCurrent const normalizedCurrent = currentUsername ? currentUsername.toLowerCase() : null; - return Object.entries(userUuids).map(([username, uuid]) => ({ - username, // Original case preserved + return Object.entries(allMappings).map(([username, uuid]) => ({ + username, uuid, isCurrent: username.toLowerCase() === normalizedCurrent })); @@ -419,16 +542,20 @@ function setUuidForUser(username, uuid) { const displayName = username.trim(); const normalizedLookup = displayName.toLowerCase(); + + // 1. Update UUID store (source of truth) + migrateUuidStoreIfNeeded(); + const uuidStore = loadUuidStore(); + const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup); + if (storeKey) delete uuidStore[storeKey]; + uuidStore[displayName] = uuid; + saveUuidStore(uuidStore); + + // 2. Update config.json (backward compat) const config = loadConfig(); const userUuids = config.userUuids || {}; - - // Remove any existing entry with same name (case-insensitive) const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup); - if (existingKey) { - delete userUuids[existingKey]; - } - - // Store with original case + if (existingKey) delete userUuids[existingKey]; userUuids[displayName] = uuid; saveConfig({ userUuids }); @@ -454,20 +581,30 @@ function deleteUuidForUser(username) { } const normalizedLookup = username.trim().toLowerCase(); + let deleted = false; + + // 1. Delete from UUID store (source of truth) + migrateUuidStoreIfNeeded(); + const uuidStore = loadUuidStore(); + const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup); + if (storeKey) { + delete uuidStore[storeKey]; + saveUuidStore(uuidStore); + deleted = true; + } + + // 2. Delete from config.json (backward compat) const config = loadConfig(); const userUuids = config.userUuids || {}; - - // Case-insensitive lookup const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup); - if (existingKey) { delete userUuids[existingKey]; saveConfig({ userUuids }); - console.log(`[Config] UUID deleted for "${username}"`); - return true; + deleted = true; } - return false; + if (deleted) console.log(`[Config] UUID deleted for "${username}"`); + return deleted; } /** @@ -788,5 +925,6 @@ module.exports = { loadVersionBranch, // Constants - CONFIG_FILE + CONFIG_FILE, + UUID_STORE_FILE }; diff --git a/backend/managers/gameLauncher.js b/backend/managers/gameLauncher.js index 4e66906..832357e 100644 --- a/backend/managers/gameLauncher.js +++ b/backend/managers/gameLauncher.js @@ -250,6 +250,7 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO } const uuid = getUuidForUser(playerName); + console.log(`[Launcher] UUID for "${playerName}": ${uuid} (verify this stays constant across launches)`); // Fetch tokens from auth server if (progressCallback) { diff --git a/package.json b/package.json index a833176..606cdaa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hytale-f2p-launcher", - "version": "2.3.5", + "version": "2.3.6", "description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support", "homepage": "https://git.sanhost.net/sanasol/hytale-f2p", "main": "main.js", diff --git a/test-uuid-persistence.js b/test-uuid-persistence.js new file mode 100644 index 0000000..33fa7f0 --- /dev/null +++ b/test-uuid-persistence.js @@ -0,0 +1,523 @@ +#!/usr/bin/env node +/** + * UUID Persistence Tests + * + * Simulates the exact conditions that caused character data loss: + * - Config file corruption during updates + * - File locks making config temporarily unreadable + * - Username re-entry after config wipe + * + * Run: node test-uuid-persistence.js + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Use a temp directory so we don't mess with real config +const TEST_DIR = path.join(os.tmpdir(), 'hytale-uuid-test-' + Date.now()); +const CONFIG_FILE = path.join(TEST_DIR, 'config.json'); +const CONFIG_BACKUP = path.join(TEST_DIR, 'config.json.bak'); +const CONFIG_TEMP = path.join(TEST_DIR, 'config.json.tmp'); +const UUID_STORE_FILE = path.join(TEST_DIR, 'uuid-store.json'); + +// Track test results +let passed = 0; +let failed = 0; +const failures = []; + +function assert(condition, message) { + if (condition) { + passed++; + console.log(` ✓ ${message}`); + } else { + failed++; + failures.push(message); + console.log(` ✗ FAIL: ${message}`); + } +} + +function assertEqual(actual, expected, message) { + if (actual === expected) { + passed++; + console.log(` ✓ ${message}`); + } else { + failed++; + failures.push(`${message} (expected: ${expected}, got: ${actual})`); + console.log(` ✗ FAIL: ${message} (expected: "${expected}", got: "${actual}")`); + } +} + +function cleanup() { + try { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true }); + } + } catch (e) {} +} + +function setup() { + cleanup(); + fs.mkdirSync(TEST_DIR, { recursive: true }); +} + +// ============================================================================ +// Inline the config functions so we can override paths +// (We can't require config.js directly because it uses hardcoded getAppDir()) +// ============================================================================ + +function validateConfig(config) { + if (!config || typeof config !== 'object') return false; + if (config.userUuids !== undefined && typeof config.userUuids !== 'object') return false; + if (config.username !== undefined && (typeof config.username !== 'string')) return false; + return true; +} + +function loadConfig() { + try { + if (fs.existsSync(CONFIG_FILE)) { + const data = fs.readFileSync(CONFIG_FILE, 'utf8'); + if (data.trim()) { + const config = JSON.parse(data); + if (validateConfig(config)) return config; + console.warn('[Config] Primary config invalid structure, trying backup...'); + } + } + } catch (err) { + console.error('[Config] Failed to load primary config:', err.message); + } + + try { + if (fs.existsSync(CONFIG_BACKUP)) { + const data = fs.readFileSync(CONFIG_BACKUP, 'utf8'); + if (data.trim()) { + const config = JSON.parse(data); + if (validateConfig(config)) { + console.log('[Config] Recovered from backup successfully'); + try { fs.writeFileSync(CONFIG_FILE, data, 'utf8'); } catch (e) {} + return config; + } + } + } + } catch (err) {} + + return {}; +} + +function saveConfig(update) { + const maxRetries = 3; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + if (!fs.existsSync(TEST_DIR)) fs.mkdirSync(TEST_DIR, { recursive: true }); + + const currentConfig = loadConfig(); + + // SAFETY CHECK: refuse to save if file exists but loaded empty + if (Object.keys(currentConfig).length === 0 && fs.existsSync(CONFIG_FILE)) { + const fileSize = fs.statSync(CONFIG_FILE).size; + if (fileSize > 2) { + console.error(`[Config] REFUSING to save — loaded empty but file exists (${fileSize} bytes). Retrying...`); + const delay = attempt * 50; // shorter delay for tests + const start = Date.now(); + while (Date.now() - start < delay) {} + continue; + } + } + + const newConfig = { ...currentConfig, ...update }; + const data = JSON.stringify(newConfig, null, 2); + + fs.writeFileSync(CONFIG_TEMP, data, 'utf8'); + const verification = JSON.parse(fs.readFileSync(CONFIG_TEMP, 'utf8')); + if (!validateConfig(verification)) throw new Error('Validation failed'); + + if (fs.existsSync(CONFIG_FILE)) { + try { + const currentData = fs.readFileSync(CONFIG_FILE, 'utf8'); + if (currentData.trim()) fs.writeFileSync(CONFIG_BACKUP, currentData, 'utf8'); + } catch (e) {} + } + + fs.renameSync(CONFIG_TEMP, CONFIG_FILE); + return true; + } catch (err) { + try { if (fs.existsSync(CONFIG_TEMP)) fs.unlinkSync(CONFIG_TEMP); } catch (e) {} + if (attempt >= maxRetries) throw err; + } + } +} + +function loadUuidStore() { + try { + if (fs.existsSync(UUID_STORE_FILE)) { + const data = fs.readFileSync(UUID_STORE_FILE, 'utf8'); + if (data.trim()) return JSON.parse(data); + } + } catch (err) {} + return {}; +} + +function saveUuidStore(store) { + const tmpFile = UUID_STORE_FILE + '.tmp'; + fs.writeFileSync(tmpFile, JSON.stringify(store, null, 2), 'utf8'); + fs.renameSync(tmpFile, UUID_STORE_FILE); +} + +function migrateUuidStoreIfNeeded() { + if (fs.existsSync(UUID_STORE_FILE)) return; + const config = loadConfig(); + if (config.userUuids && Object.keys(config.userUuids).length > 0) { + console.log('[UUID Store] Migrating', Object.keys(config.userUuids).length, 'UUIDs'); + saveUuidStore(config.userUuids); + } +} + +function getUuidForUser(username) { + const { v4: uuidv4 } = require('uuid'); + if (!username || !username.trim()) throw new Error('Username required'); + + const displayName = username.trim(); + const normalizedLookup = displayName.toLowerCase(); + + migrateUuidStoreIfNeeded(); + + // 1. Check UUID store (source of truth) + const uuidStore = loadUuidStore(); + const storeKey = Object.keys(uuidStore).find(k => k.toLowerCase() === normalizedLookup); + if (storeKey) { + const existingUuid = uuidStore[storeKey]; + if (storeKey !== displayName) { + delete uuidStore[storeKey]; + uuidStore[displayName] = existingUuid; + saveUuidStore(uuidStore); + } + // Sync to config (non-critical) + try { + const config = loadConfig(); + const configUuids = config.userUuids || {}; + const configKey = Object.keys(configUuids).find(k => k.toLowerCase() === normalizedLookup); + if (!configKey || configUuids[configKey] !== existingUuid) { + if (configKey) delete configUuids[configKey]; + configUuids[displayName] = existingUuid; + saveConfig({ userUuids: configUuids }); + } + } catch (e) {} + return existingUuid; + } + + // 2. Fallback: check config.json + const config = loadConfig(); + const userUuids = config.userUuids || {}; + const configKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup); + if (configKey) { + const recoveredUuid = userUuids[configKey]; + uuidStore[displayName] = recoveredUuid; + saveUuidStore(uuidStore); + return recoveredUuid; + } + + // 3. New user — generate UUID + const newUuid = uuidv4(); + uuidStore[displayName] = newUuid; + saveUuidStore(uuidStore); + userUuids[displayName] = newUuid; + saveConfig({ userUuids }); + return newUuid; +} + +// ============================================================================ +// OLD CODE (before fix) — for comparison testing +// ============================================================================ + +function getUuidForUser_OLD(username) { + const { v4: uuidv4 } = require('uuid'); + if (!username || !username.trim()) throw new Error('Username required'); + const displayName = username.trim(); + const normalizedLookup = displayName.toLowerCase(); + + const config = loadConfig(); + const userUuids = config.userUuids || {}; + const existingKey = Object.keys(userUuids).find(k => k.toLowerCase() === normalizedLookup); + + if (existingKey) { + return userUuids[existingKey]; + } + + // New user + const newUuid = uuidv4(); + userUuids[displayName] = newUuid; + saveConfig({ userUuids }); + return newUuid; +} + +function saveConfig_OLD(update) { + // OLD saveConfig without safety check + if (!fs.existsSync(TEST_DIR)) fs.mkdirSync(TEST_DIR, { recursive: true }); + const currentConfig = loadConfig(); + // NO SAFETY CHECK — this is the bug + const newConfig = { ...currentConfig, ...update }; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(newConfig, null, 2), 'utf8'); + return true; +} + +// ============================================================================ +// TESTS +// ============================================================================ + +console.log('\n' + '='.repeat(70)); +console.log('UUID PERSISTENCE TESTS — Simulating update corruption scenarios'); +console.log('='.repeat(70)); + +// -------------------------------------------------------------------------- +// TEST 1: Normal flow — UUID stays consistent +// -------------------------------------------------------------------------- +console.log('\n--- Test 1: Normal flow — UUID stays consistent ---'); +setup(); + +const uuid1 = getUuidForUser('SpecialK'); +const uuid2 = getUuidForUser('SpecialK'); +const uuid3 = getUuidForUser('specialk'); // case insensitive + +assertEqual(uuid1, uuid2, 'Same username returns same UUID'); +assertEqual(uuid1, uuid3, 'Case-insensitive lookup returns same UUID'); +assert(uuid1.match(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i), 'UUID is valid v4 format'); + +// -------------------------------------------------------------------------- +// TEST 2: Simulate update corruption (THE BUG) — old code +// -------------------------------------------------------------------------- +console.log('\n--- Test 2: OLD CODE — Config wipe during update loses UUID ---'); +setup(); + +// Setup: player has UUID +const oldConfig = { username: 'SpecialK', userUuids: { 'SpecialK': 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee' }, hasLaunchedBefore: true }; +fs.writeFileSync(CONFIG_FILE, JSON.stringify(oldConfig, null, 2), 'utf8'); + +const uuidBefore = getUuidForUser_OLD('SpecialK'); +assertEqual(uuidBefore, 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee', 'UUID correct before corruption'); + +// Simulate: config.json gets corrupted (loadConfig returns {} because file locked) +// This simulates what happens when saveConfig reads an empty/locked file +fs.writeFileSync(CONFIG_FILE, '', 'utf8'); // Simulate corruption: empty file + +// Old saveConfig behavior: reads empty, merges with update, saves +// This wipes userUuids +saveConfig_OLD({ hasLaunchedBefore: true }); + +const configAfterCorruption = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); +assert(!configAfterCorruption.userUuids, 'OLD CODE: userUuids wiped after corruption'); +assert(!configAfterCorruption.username, 'OLD CODE: username wiped after corruption'); + +// Player re-enters name, gets NEW UUID (character data lost!) +const uuidAfterOld = getUuidForUser_OLD('SpecialK'); +assert(uuidAfterOld !== uuidBefore, 'OLD CODE: UUID changed after corruption (BUG!)'); + +// -------------------------------------------------------------------------- +// TEST 3: NEW CODE — Config wipe during update, UUID survives via uuid-store +// -------------------------------------------------------------------------- +console.log('\n--- Test 3: NEW CODE — Config wipe + UUID survives via uuid-store ---'); +setup(); + +// Setup: player has UUID (stored in both config.json AND uuid-store.json) +const initialConfig = { username: 'SpecialK', userUuids: { 'SpecialK': 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee' }, hasLaunchedBefore: true }; +fs.writeFileSync(CONFIG_FILE, JSON.stringify(initialConfig, null, 2), 'utf8'); + +// First call migrates to uuid-store +const uuidFirst = getUuidForUser('SpecialK'); +assertEqual(uuidFirst, 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee', 'UUID correct before corruption'); +assert(fs.existsSync(UUID_STORE_FILE), 'uuid-store.json created'); + +// Simulate: config.json gets wiped (same as the update bug) +fs.writeFileSync(CONFIG_FILE, '{}', 'utf8'); + +// Verify config is empty +const wipedConfig = loadConfig(); +assert(!wipedConfig.userUuids || Object.keys(wipedConfig.userUuids).length === 0, 'Config wiped — no userUuids'); +assert(!wipedConfig.username, 'Config wiped — no username'); + +// Player re-enters same name → UUID recovered from uuid-store! +const uuidAfterNew = getUuidForUser('SpecialK'); +assertEqual(uuidAfterNew, 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee', 'NEW CODE: UUID preserved after config wipe!'); + +// -------------------------------------------------------------------------- +// TEST 4: saveConfig safety check — refuses to overwrite good data with empty +// -------------------------------------------------------------------------- +console.log('\n--- Test 4: saveConfig safety check — blocks destructive writes ---'); +setup(); + +// Setup: valid config file with data +const goodConfig = { username: 'SpecialK', userUuids: { 'SpecialK': 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee' }, hasLaunchedBefore: true, installPath: 'C:\\Games\\Hytale' }; +fs.writeFileSync(CONFIG_FILE, JSON.stringify(goodConfig, null, 2), 'utf8'); + +// Make the file temporarily unreadable by writing garbage (simulates file lock/corruption) +const originalContent = fs.readFileSync(CONFIG_FILE, 'utf8'); +fs.writeFileSync(CONFIG_FILE, 'NOT VALID JSON!!!', 'utf8'); + +// Try to save — should refuse because file exists but can't be parsed +let saveThrew = false; +try { + saveConfig({ someNewField: true }); +} catch (e) { + saveThrew = true; +} + +// The file should still have the garbage (not overwritten with { someNewField: true }) +const afterContent = fs.readFileSync(CONFIG_FILE, 'utf8'); + +// Restore original for backup recovery test +fs.writeFileSync(CONFIG_FILE, JSON.stringify(goodConfig, null, 2), 'utf8'); + +// Note: with invalid JSON, loadConfig returns {} and safety check triggers +// The save may eventually succeed on retry if the file becomes readable +// What matters is that it doesn't blindly overwrite +assert(afterContent !== '{\n "someNewField": true\n}', 'Safety check prevented blind overwrite of corrupted file'); + +// -------------------------------------------------------------------------- +// TEST 5: Backup recovery — config.json corrupted, recovered from .bak +// -------------------------------------------------------------------------- +console.log('\n--- Test 5: Backup recovery — auto-recover from .bak ---'); +setup(); + +// Create config and backup +const validConfig = { username: 'SpecialK', userUuids: { 'SpecialK': 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee' } }; +fs.writeFileSync(CONFIG_BACKUP, JSON.stringify(validConfig, null, 2), 'utf8'); +fs.writeFileSync(CONFIG_FILE, 'CORRUPTED', 'utf8'); + +const recovered = loadConfig(); +assertEqual(recovered.username, 'SpecialK', 'Username recovered from backup'); +assert(recovered.userUuids && recovered.userUuids['SpecialK'] === 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee', 'UUID recovered from backup'); + +// -------------------------------------------------------------------------- +// TEST 6: Full update simulation — the exact scenario from player report +// -------------------------------------------------------------------------- +console.log('\n--- Test 6: Full update simulation (player report scenario) ---'); +setup(); + +// Step 1: Player installs v2.3.4, sets username, plays game +console.log(' Step 1: Player sets up profile...'); +saveConfig({ username: 'Special K', hasLaunchedBefore: true }); +const originalUuid = getUuidForUser('Special K'); +console.log(` Original UUID: ${originalUuid}`); + +// Step 2: v2.3.5 auto-update — new app launches +console.log(' Step 2: Simulating v2.3.5 update...'); + +// Simulate the 3 saveConfig calls that happen during startup +// But first, simulate config being temporarily locked (returns empty) +const preUpdateContent = fs.readFileSync(CONFIG_FILE, 'utf8'); +fs.writeFileSync(CONFIG_FILE, '', 'utf8'); // Simulate: file empty during write (race condition) + +// These are the 3 calls from: profileManager.init, migrateUserDataToCentralized, handleFirstLaunchCheck +// With our safety check, they should NOT wipe the data +try { saveConfig({ hasLaunchedBefore: true }); } catch (e) { /* expected — safety check blocks it */ } + +// Simulate file becomes readable again (antivirus releases lock) +fs.writeFileSync(CONFIG_FILE, preUpdateContent, 'utf8'); + +// Step 3: Player re-enters username (because UI might show empty) +console.log(' Step 3: Player re-enters username...'); +const postUpdateUuid = getUuidForUser('Special K'); +console.log(` Post-update UUID: ${postUpdateUuid}`); + +assertEqual(postUpdateUuid, originalUuid, 'UUID survived the full update cycle!'); + +// -------------------------------------------------------------------------- +// TEST 7: Multiple users — UUIDs stay independent +// -------------------------------------------------------------------------- +console.log('\n--- Test 7: Multiple users — UUIDs stay independent ---'); +setup(); + +const uuidAlice = getUuidForUser('Alice'); +const uuidBob = getUuidForUser('Bob'); +const uuidCharlie = getUuidForUser('Charlie'); + +assert(uuidAlice !== uuidBob, 'Alice and Bob have different UUIDs'); +assert(uuidBob !== uuidCharlie, 'Bob and Charlie have different UUIDs'); + +// Wipe config, all should survive +fs.writeFileSync(CONFIG_FILE, '{}', 'utf8'); + +assertEqual(getUuidForUser('Alice'), uuidAlice, 'Alice UUID survived config wipe'); +assertEqual(getUuidForUser('Bob'), uuidBob, 'Bob UUID survived config wipe'); +assertEqual(getUuidForUser('Charlie'), uuidCharlie, 'Charlie UUID survived config wipe'); + +// -------------------------------------------------------------------------- +// TEST 8: UUID store deleted — recovery from config.json +// -------------------------------------------------------------------------- +console.log('\n--- Test 8: UUID store deleted — recovery from config.json ---'); +setup(); + +// Create UUID via normal flow (saves to both stores) +const uuidOriginal = getUuidForUser('TestPlayer'); + +// Delete uuid-store.json (simulates user manually deleting it or disk issue) +fs.unlinkSync(UUID_STORE_FILE); +assert(!fs.existsSync(UUID_STORE_FILE), 'uuid-store.json deleted'); + +// UUID should be recovered from config.json +const uuidRecovered = getUuidForUser('TestPlayer'); +assertEqual(uuidRecovered, uuidOriginal, 'UUID recovered from config.json after uuid-store deletion'); +assert(fs.existsSync(UUID_STORE_FILE), 'uuid-store.json recreated after recovery'); + +// -------------------------------------------------------------------------- +// TEST 9: Both stores deleted — new UUID generated (fresh install) +// -------------------------------------------------------------------------- +console.log('\n--- Test 9: Both stores deleted — new UUID (fresh install) ---'); +setup(); + +const uuidFresh = getUuidForUser('NewPlayer'); + +// Delete both +fs.unlinkSync(UUID_STORE_FILE); +fs.unlinkSync(CONFIG_FILE); + +const uuidAfterWipe = getUuidForUser('NewPlayer'); +assert(uuidAfterWipe !== uuidFresh, 'New UUID generated when both stores are gone (expected for true fresh install)'); + +// -------------------------------------------------------------------------- +// TEST 10: Worst case — config.json wiped AND uuid-store.json exists +// Simulates the EXACT player-reported scenario with new code +// -------------------------------------------------------------------------- +console.log('\n--- Test 10: Exact player scenario with new code ---'); +setup(); + +// Player has been playing for a while +saveConfig({ + username: 'Special K', + hasLaunchedBefore: true, + installPath: 'C:\\Games\\Hytale', + version_client: '2026.02.19-1a311a592', + version_branch: 'release', + userUuids: { 'Special K': '11111111-2222-4333-9444-555555555555' } +}); + +// First call creates uuid-store.json +const originalUuid10 = getUuidForUser('Special K'); +assertEqual(originalUuid10, '11111111-2222-4333-9444-555555555555', 'Original UUID loaded'); + +// BOOM: Update happens, config.json completely wiped +fs.writeFileSync(CONFIG_FILE, '{}', 'utf8'); + +// Username lost — player has to re-enter +const loadedUsername = loadConfig().username; +assert(!loadedUsername, 'Username is gone from config (simulating what player saw)'); + +// Player types "Special K" again in settings +saveConfig({ username: 'Special K' }); + +// Player clicks Play — getUuidForUser called +const recoveredUuid10 = getUuidForUser('Special K'); +assertEqual(recoveredUuid10, '11111111-2222-4333-9444-555555555555', 'UUID recovered — character data preserved!'); + +// ============================================================================ +// RESULTS +// ============================================================================ +console.log('\n' + '='.repeat(70)); +console.log(`RESULTS: ${passed} passed, ${failed} failed`); +if (failed > 0) { + console.log('\nFailures:'); + failures.forEach(f => console.log(` ✗ ${f}`)); +} +console.log('='.repeat(70)); + +cleanup(); +process.exit(failed > 0 ? 1 : 0);