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