v2.3.6: fix UUID loss during launcher updates

Players were losing character data (inventory, armor, backpack) after
each launcher update because config.json corruption wiped the UUID
mapping. Same username, new UUID = server treats as new player.

Fix: UUIDs now stored in separate uuid-store.json that saveConfig()
can never touch. Added safety check to refuse destructive writes
when config file exists but loads empty. Includes 28 regression tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sanasol
2026-02-20 23:10:13 +01:00
parent 6d811fd7e0
commit 4e04d657b7
4 changed files with 702 additions and 40 deletions

View File

@@ -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
};

View File

@@ -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) {

View File

@@ -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",

523
test-uuid-persistence.js Normal file
View File

@@ -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);