Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 85 additions & 38 deletions electron/data/terrorZoneNames.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,89 @@
/**
* Mapping of terror zone IDs to human-readable names.
* Based on the desecratedzones.json structure from Diablo II: Resurrected.
* Based on the desecratedzones.json structure from Diablo II: Resurrected (Reign of the Warlock expansion).
*/
export const TERROR_ZONE_NAMES: Record<number, string> = {
1: 'Burial Grounds, Crypt, and Mausoleum',
2: 'Cathedral and Catacombs',
3: 'Cold Plains and Cave',
4: 'Dark Wood and Underground Passage',
5: 'Blood Moor and Den of Evil',
6: 'Jail and Barracks',
7: 'Moo Moo Farm',
8: 'Stony Field',
9: 'Black Marsh and the Hole',
10: 'Forgotten Tower',
11: 'Pit',
12: 'Tristram',
13: 'Lut Gholein Sewers',
14: 'Stony Tomb and Rocky Waste',
15: 'Dry Hills and Halls of the Dead',
16: 'Far Oasis',
17: 'Lost City, Valley of Snakes, and Claw Viper temple',
18: 'Ancient Tunnels',
19: "Tal Rasha's Tombs",
20: 'Arcane Sanctuary',
21: 'Spider Forest and Spider Cavern',
22: 'Great Marsh',
23: 'Flayer Jungle and Flayer Dungeon',
24: 'Kurast Bazaar and Temples',
25: 'Travincal',
26: 'Durance of Hate',
27: 'Outer Steppes and Plains of Despair',
28: 'City of the Damned and River of Flame',
29: 'Chaos Sanctuary',
30: 'Bloody Foothills, Frigid Highlands, and Abbadon',
31: 'Arreat Plateau and Pit of Acheron',
32: 'Crystalline Passage and Frozen River',
33: "Nihlathak's Temple and Halls",
34: 'Glacial Trail and Drifter Cavern',
35: "Ancient's Way and Icy Cellar",
36: 'Worldstone Keep, Throne of Destruction, and Worldstone Chamber',
export const TERROR_ZONE_NAMES: Record<string, string> = {
'Act1-BurialGrounds': 'Burial Grounds, Crypt, and Mausoleum',
'Act1-Catacombs': 'Cathedral and Catacombs',
'Act1-ColdPlains': 'Cold Plains and Cave',
'Act1-DarkWood': 'Dark Wood and Underground Passage',
'Act1-BloodMoor': 'Blood Moor and Den of Evil',
'Act1-Jail': 'Jail and Barracks',
'Act1-MooMooFarm': 'Moo Moo Farm',
'Act1-StonyField': 'Stony Field',
'Act1-BlackMarsh': 'Black Marsh and the Hole',
'Act1-Tower': 'Forgotten Tower',
'Act1-Pit': 'Pit',
'Act1-Tristram': 'Tristram',
'Act1-Monastery': 'Monastery',
'Act2-Sewers': 'Lut Gholein Sewers',
'Act2-RockyWaste': 'Rocky Waste',
'Act2-DryHills': 'Dry Hills and Halls of the Dead',
'Act2-FarOasis': 'Far Oasis',
'Act2-LostCity': 'Lost City',
'Act2-TalRashas': "Tal Rasha's Tombs",
'Act2-ArcaneSanctuary': 'Arcane Sanctuary',
'Act3-SpiderForest': 'Spider Forest',
'Act3-GreatMarsh': 'Great Marsh',
'Act3-FlayerJungle': 'Flayer Jungle',
'Act3-Kurast': 'Kurast',
'Act3-Travincal': 'Travincal',
'Act3-DuranceOfHate': 'Durance of Hate',
Act4_OuterSteppes: 'Outer Steppes',
'Act4-RiverOfFlame': 'River of Flame',
'Act4-ChaosSanctuary': 'Chaos Sanctuary',
'Act5-BloodyFoothils': 'Bloody Foothills',
'Act5-ArreatPlateau': 'Arreat Plateau',
'Act5-CrystallinePassage': 'Crystalline Passage',
'Act5-Halls': "Nihlathak's Temple and Halls",
'Act5-GlacialTrail': 'Glacial Trail',
'Act5-AncientsWay': "Ancient's Way",
'Act5-FrozenTundra': 'Frozen Tundra',
'Act5-WorldstoneKeep': 'Worldstone Keep',
};

/**
* Mapping from numeric zone indices to string zone IDs.
* Uses 1-based indexing following the order of zones in desecratedzones.json.
* Used for converting legacy numeric IDs (1-34) to current string IDs.
*
* The game file has 34 zones in its zones array (zones_0 through zones_33, with gaps).
* Old user configurations used 1-based numbering (Zone 1, Zone 2, ... Zone 34).
* This mapping preserves that 1-based convention for backward compatibility.
*/
export const NUMERIC_TO_STRING_ZONE_ID: Record<number, string> = {
1: 'Act1-BurialGrounds',
2: 'Act1-Catacombs',
3: 'Act1-ColdPlains',
4: 'Act1-DarkWood',
5: 'Act1-BloodMoor',
6: 'Act1-Jail',
7: 'Act1-MooMooFarm',
8: 'Act1-Tristram',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Map legacy numeric zone IDs by ID, not by array order

migrateTerrorZoneConfig in electron/database/settings.ts depends on NUMERIC_TO_STRING_ZONE_ID, but this table reassigns legacy ID 8 to Act1-Tristram even though prior numeric IDs in this codebase used 8 for Stony Field (and 12 for Tristram). That means upgrades with existing numeric terrorZoneConfig will silently disable/enable the wrong zones after migration. The conversion table needs to preserve the old numeric ID semantics, not the new array position.

Useful? React with 👍 / 👎.

9: 'Act1-Tower',
10: 'Act1-Monastery',
11: 'Act2-Sewers',
12: 'Act2-RockyWaste',
13: 'Act2-DryHills',
14: 'Act2-FarOasis',
15: 'Act2-LostCity',
16: 'Act2-TalRashas',
17: 'Act2-ArcaneSanctuary',
18: 'Act3-SpiderForest',
19: 'Act3-GreatMarsh',
20: 'Act3-FlayerJungle',
21: 'Act3-Kurast',
22: 'Act3-Travincal',
23: 'Act3-DuranceOfHate',
24: 'Act4_OuterSteppes',
25: 'Act4-RiverOfFlame',
26: 'Act4-ChaosSanctuary',
27: 'Act5-BloodyFoothils',
28: 'Act5-ArreatPlateau',
29: 'Act5-CrystallinePassage',
30: 'Act5-Halls',
31: 'Act5-GlacialTrail',
32: 'Act5-AncientsWay',
33: 'Act5-FrozenTundra',
34: 'Act5-WorldstoneKeep',
};
Comment on lines +86 to 89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add mappings for legacy terror zone IDs 35 and 36

The migration map ends at 34, but previous persisted configs could contain IDs 35 and 36. During migrateTerrorZoneConfig, those keys are left as '35'/'36', which do not match any current string zone IDs, so user preferences for those zones are dropped and they revert to defaults after upgrade. Include explicit conversions (or a deterministic fallback) for all legacy IDs to avoid silent data loss.

Useful? React with 👍 / 👎.

3 changes: 3 additions & 0 deletions electron/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,14 @@ class GrailDatabase {
/**
* Initializes the database schema by creating tables and indexes.
* This method is called during database construction and handles schema creation errors.
* Also runs cleanup operations to fix corrupted settings.
* @throws {Error} If schema initialization fails
*/
private initializeSchema(): void {
try {
schemaModule.createSchema(this);
// Clean up any corrupted settings from previous versions
settingsModule.cleanupCorruptedSettings(this);
} catch (error) {
console.error('Failed to initialize database schema:', error);
throw error;
Expand Down
97 changes: 85 additions & 12 deletions electron/database/settings.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,47 @@
import { eq } from 'drizzle-orm';
import { NUMERIC_TO_STRING_ZONE_ID } from '../data/terrorZoneNames';
import type { Settings } from '../types/grail';
import { GameMode, GameVersion } from '../types/grail';
import { schema } from './drizzle';
import type { DatabaseContext } from './types';

const { settings } = schema;

function parseJSON(jsonString: string): unknown {
// Track which setting keys have been warned about to avoid log spam
const warnedKeys = new Set<string>();

function parseJSON(jsonString: string, settingKey?: string): unknown {
if (!jsonString || jsonString === 'undefined' || jsonString === 'null') {
return undefined;
}
if (jsonString === '[object Object]') {
console.warn(
'[parseJSON] Detected "[object Object]" string — an object was likely coerced to string before storage.',
);
// Only warn once per setting key to avoid log spam
if (settingKey && !warnedKeys.has(settingKey)) {
console.warn(
`[parseJSON] Setting "${settingKey}" has corrupted value "[object Object]". This will be ignored.`,
);
warnedKeys.add(settingKey);
}
return undefined;
}
try {
return JSON.parse(jsonString);
} catch {
console.warn(`Failed to parse JSON setting: "${jsonString}". Using undefined.`);
if (settingKey && !warnedKeys.has(settingKey)) {
console.warn(
`[parseJSON] Failed to parse setting "${settingKey}" value: "${jsonString}". Using undefined.`,
);
warnedKeys.add(settingKey);
}
return undefined;
}
}

function parseJSONSetting<T>(value: string | undefined): T | undefined {
function parseJSONSetting<T>(value: string | undefined, settingKey?: string): T | undefined {
if (!value || value === '') {
return undefined;
}
return parseJSON(value) as T | undefined;
return parseJSON(value, settingKey) as T | undefined;
}

function parseIntSetting(value: string | undefined): number | undefined {
Expand All @@ -50,6 +64,34 @@ function parseEnumSetting<T>(value: string | undefined, defaultValue: T): T {
return (value as T) || defaultValue;
}

/**
* Migrates terror zone configuration from old numeric IDs to new string IDs.
* @param config - The config object that may contain numeric or string keys
* @returns Migrated config with only string keys
*/
function migrateTerrorZoneConfig(
config: Record<string | number, boolean> | undefined,
): Record<string, boolean> | undefined {
if (!config || Object.keys(config).length === 0) {
return undefined;
}

const migrated: Record<string, boolean> = {};
for (const [key, value] of Object.entries(config)) {
const numericKey = Number(key);
// Check if the key is a numeric string and can be converted to a number
if (!Number.isNaN(numericKey) && NUMERIC_TO_STRING_ZONE_ID[numericKey]) {
// Migrate from numeric to string ID
migrated[NUMERIC_TO_STRING_ZONE_ID[numericKey]] = value;
} else {
// Already a string ID, keep as is
migrated[key] = value;
}
}

return migrated;
}

export function getAllSettings(ctx: DatabaseContext): Settings {
const dbSettings = ctx.db.select().from(settings).all();
const settingsMap: Record<string, string> = {};
Expand Down Expand Up @@ -84,6 +126,7 @@ export function getAllSettings(ctx: DatabaseContext): Settings {
| undefined,
iconConversionProgress: parseJSONSetting<{ current: number; total: number }>(
settingsMap.iconConversionProgress,
'iconConversionProgress',
),
// Advanced monitoring settings
tickReaderIntervalMs: parseIntSetting(settingsMap.tickReaderIntervalMs),
Expand All @@ -93,27 +136,40 @@ export function getAllSettings(ctx: DatabaseContext): Settings {
// Widget settings
widgetEnabled: parseBooleanSetting(settingsMap.widgetEnabled),
widgetDisplay: parseEnumSetting(settingsMap.widgetDisplay, 'overall' as const),
widgetPosition: parseJSONSetting<{ x: number; y: number }>(settingsMap.widgetPosition),
widgetPosition: parseJSONSetting<{ x: number; y: number }>(
settingsMap.widgetPosition,
'widgetPosition',
),
widgetOpacity: parseFloatSetting(settingsMap.widgetOpacity, 0.9) ?? 0.9,
widgetSizeOverall: parseJSONSetting<{ width: number; height: number }>(
settingsMap.widgetSizeOverall,
'widgetSizeOverall',
),
widgetSizeSplit: parseJSONSetting<{ width: number; height: number }>(
settingsMap.widgetSizeSplit,
'widgetSizeSplit',
),
widgetSizeAll: parseJSONSetting<{ width: number; height: number }>(
settingsMap.widgetSizeAll,
'widgetSizeAll',
),
widgetSizeAll: parseJSONSetting<{ width: number; height: number }>(settingsMap.widgetSizeAll),
// Main window settings
mainWindowBounds: parseJSONSetting<{
x: number;
y: number;
width: number;
height: number;
}>(settingsMap.mainWindowBounds),
}>(settingsMap.mainWindowBounds, 'mainWindowBounds'),
// Wizard settings
wizardCompleted: parseBooleanSetting(settingsMap.wizardCompleted),
wizardSkipped: parseBooleanSetting(settingsMap.wizardSkipped),
// Terror zone configuration
terrorZoneConfig: parseJSONSetting<Record<number, boolean>>(settingsMap.terrorZoneConfig),
// Terror zone configuration (with migration from numeric to string IDs)
terrorZoneConfig: migrateTerrorZoneConfig(
parseJSONSetting<Record<string | number, boolean>>(
settingsMap.terrorZoneConfig,
'terrorZoneConfig',
),
),
terrorZoneBackupCreated: parseBooleanSetting(settingsMap.terrorZoneBackupCreated),
// Run tracker settings
runTrackerAutoStart: parseBooleanSetting(settingsMap.runTrackerAutoStart),
Expand All @@ -123,6 +179,7 @@ export function getAllSettings(ctx: DatabaseContext): Settings {
parseIntSetting(settingsMap.runTrackerMemoryPollingInterval) ?? 500,
runTrackerShortcuts: parseJSONSetting<Settings['runTrackerShortcuts']>(
settingsMap.runTrackerShortcuts,
'runTrackerShortcuts',
),
};

Expand All @@ -139,3 +196,19 @@ export function setSetting(ctx: DatabaseContext, key: keyof Settings, value: str
})
.run();
}

/**
* Cleans up corrupted settings that have the value "[object Object]".
* This can happen when an object is coerced to a string instead of being JSON.stringify'd.
* @param ctx - Database context
* @returns Number of corrupted settings cleaned up
*/
export function cleanupCorruptedSettings(ctx: DatabaseContext): number {
const result = ctx.db.delete(settings).where(eq(settings.value, '[object Object]')).run();

if (result.changes > 0) {
console.log(`[cleanupCorruptedSettings] Cleaned up ${result.changes} corrupted setting(s)`);
}

return result.changes;
}
9 changes: 4 additions & 5 deletions electron/ipc-handlers/terrorZoneHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function initializeTerrorZoneHandlers(): void {
* IPC handler for retrieving current terror zone configuration from database.
* @returns Promise resolving to zone configuration (zone ID -> enabled state)
*/
ipcMain.handle('terrorZone:getConfig', async (): Promise<Record<number, boolean>> => {
ipcMain.handle('terrorZone:getConfig', async (): Promise<Record<string, boolean>> => {
try {
const settings = grailDatabase.getAllSettings();
return settings.terrorZoneConfig || {};
Expand All @@ -61,7 +61,7 @@ export function initializeTerrorZoneHandlers(): void {
'terrorZone:updateConfig',
async (
_,
config: Record<number, boolean>,
config: Record<string, boolean>,
): Promise<{ success: boolean; requiresRestart: boolean }> => {
try {
const settings = grailDatabase.getAllSettings();
Expand Down Expand Up @@ -90,9 +90,8 @@ export function initializeTerrorZoneHandlers(): void {
});

// Convert config to Set of enabled zone IDs
const enabledZoneIds = new Set<number>();
for (const [zoneIdStr, enabled] of Object.entries(config)) {
const zoneId = Number.parseInt(zoneIdStr, 10);
const enabledZoneIds = new Set<string>();
for (const [zoneId, enabled] of Object.entries(config)) {
if (enabled) {
enabledZoneIds.add(zoneId);
}
Expand Down
8 changes: 4 additions & 4 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,17 +753,17 @@ contextBridge.exposeInMainWorld('electronAPI', {

/**
* Retrieves current terror zone configuration from database.
* @returns {Promise<Record<number, boolean>>} A promise that resolves with zone configuration.
* @returns {Promise<Record<string, boolean>>} A promise that resolves with zone configuration.
*/
getConfig: (): Promise<Record<number, boolean>> => ipcRenderer.invoke('terrorZone:getConfig'),
getConfig: (): Promise<Record<string, boolean>> => ipcRenderer.invoke('terrorZone:getConfig'),

/**
* Updates terror zone configuration and applies to game file.
* @param {Record<number, boolean>} config - Zone configuration (zone ID -> enabled state).
* @param {Record<string, boolean>} config - Zone configuration (zone ID -> enabled state).
* @returns {Promise<{ success: boolean; requiresRestart: boolean }>} A promise that resolves with update result.
*/
updateConfig: (
config: Record<number, boolean>,
config: Record<string, boolean>,
): Promise<{ success: boolean; requiresRestart: boolean }> =>
ipcRenderer.invoke('terrorZone:updateConfig', config),

Expand Down
Loading
Loading