#!/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);