mirror of
https://git.sanhost.net/sanasol/hytale-f2p
synced 2026-02-26 11:41:49 -03:00
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:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user