diff --git a/electron/data/terrorZoneNames.ts b/electron/data/terrorZoneNames.ts index 6ca4047..f7a1ff4 100644 --- a/electron/data/terrorZoneNames.ts +++ b/electron/data/terrorZoneNames.ts @@ -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 = { - 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 = { + '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 = { + 1: 'Act1-BurialGrounds', + 2: 'Act1-Catacombs', + 3: 'Act1-ColdPlains', + 4: 'Act1-DarkWood', + 5: 'Act1-BloodMoor', + 6: 'Act1-Jail', + 7: 'Act1-MooMooFarm', + 8: 'Act1-Tristram', + 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', }; diff --git a/electron/database/database.ts b/electron/database/database.ts index 2cae39b..132fd0f 100644 --- a/electron/database/database.ts +++ b/electron/database/database.ts @@ -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; diff --git a/electron/database/settings.ts b/electron/database/settings.ts index 8aaa85d..41a07c4 100644 --- a/electron/database/settings.ts +++ b/electron/database/settings.ts @@ -1,3 +1,5 @@ +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'; @@ -5,29 +7,41 @@ 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(); + +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(value: string | undefined): T | undefined { +function parseJSONSetting(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 { @@ -50,6 +64,34 @@ function parseEnumSetting(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 | undefined, +): Record | undefined { + if (!config || Object.keys(config).length === 0) { + return undefined; + } + + const migrated: Record = {}; + 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 = {}; @@ -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), @@ -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>(settingsMap.terrorZoneConfig), + // Terror zone configuration (with migration from numeric to string IDs) + terrorZoneConfig: migrateTerrorZoneConfig( + parseJSONSetting>( + settingsMap.terrorZoneConfig, + 'terrorZoneConfig', + ), + ), terrorZoneBackupCreated: parseBooleanSetting(settingsMap.terrorZoneBackupCreated), // Run tracker settings runTrackerAutoStart: parseBooleanSetting(settingsMap.runTrackerAutoStart), @@ -123,6 +179,7 @@ export function getAllSettings(ctx: DatabaseContext): Settings { parseIntSetting(settingsMap.runTrackerMemoryPollingInterval) ?? 500, runTrackerShortcuts: parseJSONSetting( settingsMap.runTrackerShortcuts, + 'runTrackerShortcuts', ), }; @@ -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; +} diff --git a/electron/ipc-handlers/terrorZoneHandlers.ts b/electron/ipc-handlers/terrorZoneHandlers.ts index 542f8ac..505eddc 100644 --- a/electron/ipc-handlers/terrorZoneHandlers.ts +++ b/electron/ipc-handlers/terrorZoneHandlers.ts @@ -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> => { + ipcMain.handle('terrorZone:getConfig', async (): Promise> => { try { const settings = grailDatabase.getAllSettings(); return settings.terrorZoneConfig || {}; @@ -61,7 +61,7 @@ export function initializeTerrorZoneHandlers(): void { 'terrorZone:updateConfig', async ( _, - config: Record, + config: Record, ): Promise<{ success: boolean; requiresRestart: boolean }> => { try { const settings = grailDatabase.getAllSettings(); @@ -90,9 +90,8 @@ export function initializeTerrorZoneHandlers(): void { }); // Convert config to Set of enabled zone IDs - const enabledZoneIds = new Set(); - for (const [zoneIdStr, enabled] of Object.entries(config)) { - const zoneId = Number.parseInt(zoneIdStr, 10); + const enabledZoneIds = new Set(); + for (const [zoneId, enabled] of Object.entries(config)) { if (enabled) { enabledZoneIds.add(zoneId); } diff --git a/electron/preload.ts b/electron/preload.ts index 4f0cecf..b7b233e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -753,17 +753,17 @@ contextBridge.exposeInMainWorld('electronAPI', { /** * Retrieves current terror zone configuration from database. - * @returns {Promise>} A promise that resolves with zone configuration. + * @returns {Promise>} A promise that resolves with zone configuration. */ - getConfig: (): Promise> => ipcRenderer.invoke('terrorZone:getConfig'), + getConfig: (): Promise> => ipcRenderer.invoke('terrorZone:getConfig'), /** * Updates terror zone configuration and applies to game file. - * @param {Record} config - Zone configuration (zone ID -> enabled state). + * @param {Record} config - Zone configuration (zone ID -> enabled state). * @returns {Promise<{ success: boolean; requiresRestart: boolean }>} A promise that resolves with update result. */ updateConfig: ( - config: Record, + config: Record, ): Promise<{ success: boolean; requiresRestart: boolean }> => ipcRenderer.invoke('terrorZone:updateConfig', config), diff --git a/electron/services/terrorZoneService.test.ts b/electron/services/terrorZoneService.test.ts index de14a71..c164634 100644 --- a/electron/services/terrorZoneService.test.ts +++ b/electron/services/terrorZoneService.test.ts @@ -11,12 +11,14 @@ vi.mock('electron', () => ({ }, })); -const createZonesJson = (ids: number[]): string => +const createZonesJson = (ids: (string | number)[]): string => JSON.stringify( { desecrated_zones: [ { zones: ids.map((id) => ({ + type: 'DesecratedZone', + name: `zone_${id}`, id, levels: [], })), @@ -38,7 +40,11 @@ describe('TerrorZoneService', () => { mkdirSync(mockUserDataPath, { recursive: true }); gameFilePath = path.join(tempDir, 'desecratedzones.json'); - writeFileSync(gameFilePath, createZonesJson([1, 2, 3]), 'utf-8'); + writeFileSync( + gameFilePath, + createZonesJson(['Act1-BurialGrounds', 'Act1-Catacombs', 'Act1-ColdPlains']), + 'utf-8', + ); service = new TerrorZoneService(); }); @@ -50,25 +56,63 @@ describe('TerrorZoneService', () => { it('prefers the backup file when reading zones', async () => { await service.readZonesFromFile(gameFilePath, { preferBackup: true }); - writeFileSync(gameFilePath, createZonesJson([1, 2]), 'utf-8'); + writeFileSync(gameFilePath, createZonesJson(['Act1-BurialGrounds', 'Act1-Catacombs']), 'utf-8'); const zones = await service.readZonesFromFile(gameFilePath, { preferBackup: true }); - expect(zones.map((zone) => zone.id)).toEqual([1, 2, 3]); + expect(zones.map((zone) => zone.id)).toEqual([ + 'Act1-BurialGrounds', + 'Act1-Catacombs', + 'Act1-ColdPlains', + ]); }); it('re-adds previously disabled zones when config enables them again', async () => { const zones = await service.readZonesFromFile(gameFilePath, { preferBackup: true }); - const enabledSet = new Set([1, 2]); + const enabledSet = new Set(['Act1-BurialGrounds', 'Act1-Catacombs']); await service.writeZonesToFile(gameFilePath, zones, enabledSet); const restoredZones = await service.readZonesFromFile(gameFilePath, { preferBackup: true }); - const reEnableSet = new Set([1, 2, 3]); + const reEnableSet = new Set([ + 'Act1-BurialGrounds', + 'Act1-Catacombs', + 'Act1-ColdPlains', + ]); await service.writeZonesToFile(gameFilePath, restoredZones, reEnableSet); const file = JSON.parse(readFileSync(gameFilePath, 'utf-8')); - const zoneIds = file.desecrated_zones[0].zones.map((zone: { id: number }) => zone.id); + const zoneIds = file.desecrated_zones[0].zones.map((zone: { id: string }) => zone.id); + + expect(zoneIds).toEqual(['Act1-BurialGrounds', 'Act1-Catacombs', 'Act1-ColdPlains']); + }); + + it('converts numeric zone IDs to string IDs and assigns proper names', async () => { + // Create a file with numeric zone IDs (1-based indexing from legacy config) + writeFileSync(gameFilePath, createZonesJson([1, 2, 3]), 'utf-8'); + + const zones = await service.readZonesFromFile(gameFilePath, { preferBackup: true }); + + // Verify IDs are converted to strings + expect(zones.map((zone) => zone.id)).toEqual([ + 'Act1-BurialGrounds', + 'Act1-Catacombs', + 'Act1-ColdPlains', + ]); + + // Verify proper names are assigned + expect(zones[0].name).toBe('Burial Grounds, Crypt, and Mausoleum'); + expect(zones[1].name).toBe('Cathedral and Catacombs'); + expect(zones[2].name).toBe('Cold Plains and Cave'); + }); + + it('handles unknown numeric zone IDs gracefully', async () => { + // Create a file with an unknown numeric zone ID + writeFileSync(gameFilePath, createZonesJson([999]), 'utf-8'); + + const zones = await service.readZonesFromFile(gameFilePath, { preferBackup: true }); - expect(zoneIds).toEqual([1, 2, 3]); + // Verify fallback behavior for unknown ID + expect(zones[0].id).toBe('999'); + expect(zones[0].name).toBe('Zone 999'); }); }); diff --git a/electron/services/terrorZoneService.ts b/electron/services/terrorZoneService.ts index f68deef..cd5b239 100644 --- a/electron/services/terrorZoneService.ts +++ b/electron/services/terrorZoneService.ts @@ -1,7 +1,7 @@ import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { app } from 'electron'; -import { TERROR_ZONE_NAMES } from '../data/terrorZoneNames'; +import { NUMERIC_TO_STRING_ZONE_ID, TERROR_ZONE_NAMES } from '../data/terrorZoneNames'; import { stripJsonComments } from '../lib/jsonUtils'; import type { TerrorZone } from '../types/grail'; @@ -97,13 +97,43 @@ export class TerrorZoneService { return zones.map( (zone: { - id: number; - levels?: Array<{ level_id: number; waypoint_level_id?: number }>; - }) => ({ - id: zone.id, - name: TERROR_ZONE_NAMES[zone.id] || `Zone ${zone.id}`, - levels: zone.levels || [], - }), + type?: string; + name?: string; + id: string | number; + levels?: Array<{ + type?: string; + name?: string; + level_id: number; + waypoint_level_id?: number; + }>; + }) => { + // Convert numeric IDs to string IDs if needed + let zoneId: string; + let zoneName: string; + + if (typeof zone.id === 'number') { + // Numeric ID - convert to string ID first, then get name + const stringId = NUMERIC_TO_STRING_ZONE_ID[zone.id]; + zoneId = stringId || String(zone.id); + zoneName = stringId + ? TERROR_ZONE_NAMES[stringId] || `Zone ${zone.id}` + : `Zone ${zone.id}`; + } else { + // String ID - use directly + zoneId = zone.id; + zoneName = TERROR_ZONE_NAMES[zone.id] || `Zone ${zone.id}`; + } + + return { + id: zoneId, + name: zoneName, + levels: + zone.levels?.map((level) => ({ + level_id: level.level_id, + waypoint_level_id: level.waypoint_level_id, + })) || [], + }; + }, ); } catch (error) { log.error('readZonesFromFile', error); @@ -120,7 +150,7 @@ export class TerrorZoneService { async writeZonesToFile( filePath: string, zones: TerrorZone[], - enabledZoneIds: Set, + enabledZoneIds: Set, ): Promise { try { if (!existsSync(filePath)) { @@ -138,14 +168,35 @@ export class TerrorZoneService { throw new Error('Invalid desecratedzones.json structure'); } + // Read the original structure to preserve type and name fields + const originalZones = data.desecrated_zones[0]?.zones || []; + const originalZonesMap = new Map( + originalZones.map((zone: { id: string; type?: string; name?: string }) => [ + zone.id, + { type: zone.type, name: zone.name }, + ]), + ); + // Filter zones to only include enabled ones const enabledZones = zones.filter((zone) => enabledZoneIds.has(zone.id)); - // Update the zones array in the data structure - data.desecrated_zones[0].zones = enabledZones.map((zone) => ({ - id: zone.id, - levels: zone.levels, - })); + // Update the zones array in the data structure, preserving type and name fields + data.desecrated_zones[0].zones = enabledZones.map((zone) => { + const original = originalZonesMap.get(zone.id) || {}; + return { + ...(original.type && { type: original.type }), + ...(original.name && { name: original.name }), + id: zone.id, + levels: zone.levels.map((level) => ({ + type: 'DesecratedLevel', + name: `desecratedzones_desecrated_zones_0_zones_${zone.id}_levels_${level.level_id}`, + level_id: level.level_id, + ...(level.waypoint_level_id !== undefined && { + waypoint_level_id: level.waypoint_level_id, + }), + })), + }; + }); // Write back to file with proper formatting writeFileSync(filePath, JSON.stringify(data, null, 4), 'utf-8'); diff --git a/electron/types/electron.d.ts b/electron/types/electron.d.ts index 600901d..0b3675d 100644 --- a/electron/types/electron.d.ts +++ b/electron/types/electron.d.ts @@ -629,16 +629,16 @@ export interface ElectronAPI { /** * Retrieves current terror zone configuration from database. - * @returns {Promise>} A promise that resolves with zone configuration. + * @returns {Promise>} A promise that resolves with zone configuration. */ - getConfig(): Promise> + getConfig(): Promise> /** * Updates terror zone configuration and applies to game file. - * @param {Record} config - Zone configuration (zone ID -> enabled state). + * @param {Record} config - Zone configuration (zone ID -> enabled state). * @returns {Promise<{ success: boolean; requiresRestart: boolean }>} A promise that resolves with update result. */ - updateConfig(config: Record): Promise<{ success: boolean; requiresRestart: boolean }> + updateConfig(config: Record): Promise<{ success: boolean; requiresRestart: boolean }> /** * Restores the original desecratedzones.json file from backup. diff --git a/electron/types/grail.ts b/electron/types/grail.ts index d2985b8..4f27cd3 100644 --- a/electron/types/grail.ts +++ b/electron/types/grail.ts @@ -553,7 +553,7 @@ export type Settings = { wizardCompleted?: boolean; // Whether the setup wizard has been completed wizardSkipped?: boolean; // Whether the user skipped the setup wizard // Terror zone configuration - terrorZoneConfig?: Record; // Zone ID -> enabled state + terrorZoneConfig?: Record; // Zone ID -> enabled state terrorZoneBackupCreated?: boolean; // Whether backup has been created // Run tracker settings runTrackerAutoStart?: boolean; // Whether to automatically start runs when save files are modified @@ -581,7 +581,7 @@ export type DatabaseSetting = { * Interface representing a terror zone configuration. */ export interface TerrorZone { - id: number; + id: string; name: string; levels: Array<{ level_id: number; waypoint_level_id?: number }>; } diff --git a/src/components/grail/ItemCard.test.tsx b/src/components/grail/ItemCard.test.tsx index 550b367..afef0ed 100644 --- a/src/components/grail/ItemCard.test.tsx +++ b/src/components/grail/ItemCard.test.tsx @@ -280,7 +280,8 @@ describe('When ItemCard is rendered', () => { // Act render(); const card = screen.getByText('Clickable').closest('[class*="rounded-lg"]'); - fireEvent.click(card!); + expect(card).toBeTruthy(); + fireEvent.click(card as Element); // Assert expect(onClick).toHaveBeenCalledTimes(1); @@ -294,7 +295,8 @@ describe('When ItemCard is rendered', () => { // Act render(); const card = screen.getByText('Pressable').closest('[class*="rounded-lg"]'); - fireEvent.keyDown(card!, { key: 'Enter' }); + expect(card).toBeTruthy(); + fireEvent.keyDown(card as Element, { key: 'Enter' }); // Assert expect(onClick).toHaveBeenCalledTimes(1); @@ -308,7 +310,8 @@ describe('When ItemCard is rendered', () => { // Act render(); const card = screen.getByText('Spaceable').closest('[class*="rounded-lg"]'); - fireEvent.keyDown(card!, { key: ' ' }); + expect(card).toBeTruthy(); + fireEvent.keyDown(card as Element, { key: ' ' }); // Assert expect(onClick).toHaveBeenCalledTimes(1); @@ -322,7 +325,8 @@ describe('When ItemCard is rendered', () => { // Act render(); const card = screen.getByText('Ignorable').closest('[class*="rounded-lg"]'); - fireEvent.keyDown(card!, { key: 'Tab' }); + expect(card).toBeTruthy(); + fireEvent.keyDown(card as Element, { key: 'Tab' }); // Assert expect(onClick).not.toHaveBeenCalled(); diff --git a/src/components/grail/ItemDetailsDialog.test.tsx b/src/components/grail/ItemDetailsDialog.test.tsx index c4e7d36..5d49aff 100644 --- a/src/components/grail/ItemDetailsDialog.test.tsx +++ b/src/components/grail/ItemDetailsDialog.test.tsx @@ -431,8 +431,9 @@ describe('When ItemDetailsDialog is rendered', () => { // The footer Close button is the one without data-slot="dialog-close" const footerClose = closeButtons.find( (btn) => btn.getAttribute('data-slot') !== 'dialog-close', - )!; - fireEvent.click(footerClose); + ); + expect(footerClose).toBeTruthy(); + fireEvent.click(footerClose as Element); // Assert expect(onOpenChange).toHaveBeenCalledWith(false); diff --git a/src/components/runtracker/RunList.test.tsx b/src/components/runtracker/RunList.test.tsx index d08a1d3..82c4dc0 100644 --- a/src/components/runtracker/RunList.test.tsx +++ b/src/components/runtracker/RunList.test.tsx @@ -205,7 +205,8 @@ describe('When RunList is rendered', () => { // Assert // The items column shows "-" when there are no items const row = screen.getByText('#1').closest('tr'); - expect(within(row!).getAllByText('-').length).toBeGreaterThan(0); + expect(row).toBeTruthy(); + expect(within(row as HTMLElement).getAllByText('-').length).toBeGreaterThan(0); }); }); @@ -224,7 +225,8 @@ describe('When RunList is rendered', () => { // Assert const row = screen.getByText('#1').closest('tr'); - const dashes = within(row!).getAllByText('-'); + expect(row).toBeTruthy(); + const dashes = within(row as HTMLElement).getAllByText('-'); expect(dashes.length).toBeGreaterThanOrEqual(2); }); }); @@ -294,7 +296,9 @@ describe('When RunList is rendered', () => { render(); // Act - fireEvent.click(screen.getByText('#1').closest('tr')!); + const row = screen.getByText('#1').closest('tr'); + expect(row).toBeTruthy(); + fireEvent.click(row as HTMLElement); // Assert — dialog should open with run title expect(screen.getByText('Run #1')).toBeInTheDocument(); @@ -306,7 +310,9 @@ describe('When RunList is rendered', () => { render(); // Act - fireEvent.click(screen.getByText('#1').closest('tr')!); + const row = screen.getByText('#1').closest('tr'); + expect(row).toBeTruthy(); + fireEvent.click(row as HTMLElement); // Assert expect(mockLoadRunItems).toHaveBeenCalledWith('run-1'); @@ -320,7 +326,9 @@ describe('When RunList is rendered', () => { render(); // Act - fireEvent.keyDown(screen.getByText('#1').closest('tr')!, { key: 'Enter' }); + const row = screen.getByText('#1').closest('tr'); + expect(row).toBeTruthy(); + fireEvent.keyDown(row as HTMLElement, { key: 'Enter' }); // Assert expect(screen.getByText('Run #1')).toBeInTheDocument(); @@ -334,7 +342,9 @@ describe('When RunList is rendered', () => { render(); // Act - fireEvent.keyDown(screen.getByText('#1').closest('tr')!, { key: ' ' }); + const row = screen.getByText('#1').closest('tr'); + expect(row).toBeTruthy(); + fireEvent.keyDown(row as HTMLElement, { key: ' ' }); // Assert expect(screen.getByText('Run #1')).toBeInTheDocument(); @@ -357,7 +367,9 @@ describe('When RunDetailsDialog is rendered', () => { render(); // Act — open dialog - fireEvent.click(screen.getByText('#1').closest('tr')!); + const row = screen.getByText('#1').closest('tr'); + expect(row).toBeTruthy(); + fireEvent.click(row as HTMLElement); // Assert expect(screen.getByText('No items found in this run.')).toBeInTheDocument(); @@ -387,7 +399,9 @@ describe('When RunDetailsDialog is rendered', () => { render(); // Act — open dialog - fireEvent.click(screen.getByText('#1').closest('tr')!); + const row = screen.getByText('#1').closest('tr'); + expect(row).toBeTruthy(); + fireEvent.click(row as HTMLElement); // Assert expect(screen.getByTestId('item-card')).toBeInTheDocument(); @@ -410,7 +424,9 @@ describe('When RunDetailsDialog is rendered', () => { render(); // Act — open dialog - fireEvent.click(screen.getByText('#1').closest('tr')!); + const row = screen.getByText('#1').closest('tr'); + expect(row).toBeTruthy(); + fireEvent.click(row as HTMLElement); // Assert expect(screen.getByText('Manual Item')).toBeInTheDocument(); @@ -425,7 +441,9 @@ describe('When RunDetailsDialog is rendered', () => { render(); // Act — open dialog - fireEvent.click(screen.getByText('#1').closest('tr')!); + const row = screen.getByText('#1').closest('tr'); + expect(row).toBeTruthy(); + fireEvent.click(row as HTMLElement); // Assert — skeleton elements have specific class const dialog = screen.getByText('Run #1').closest('[role="dialog"]'); diff --git a/src/components/terror-zone/TerrorZoneConfiguration.tsx b/src/components/terror-zone/TerrorZoneConfiguration.tsx index 4e319cc..ea2574d 100644 --- a/src/components/terror-zone/TerrorZoneConfiguration.tsx +++ b/src/components/terror-zone/TerrorZoneConfiguration.tsx @@ -29,7 +29,7 @@ import { translations } from '@/i18n/translations'; export function TerrorZoneConfiguration() { const { t } = useTranslation(); const [zones, setZones] = useState([]); - const [config, setConfig] = useState>({}); + const [config, setConfig] = useState>({}); const [searchTerm, setSearchTerm] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -77,7 +77,7 @@ export function TerrorZoneConfiguration() { }, [loadData]); const handleZoneToggle = useCallback( - async (zoneId: number, enabled: boolean) => { + async (zoneId: string, enabled: boolean) => { try { setIsSaving(true); const newConfig = { ...config, [zoneId]: enabled }; @@ -102,7 +102,7 @@ export function TerrorZoneConfiguration() { const handleEnableAll = useCallback(async () => { try { setIsSaving(true); - const newConfig: Record = {}; + const newConfig: Record = {}; zones.forEach((zone) => { newConfig[zone.id] = true; }); @@ -124,7 +124,7 @@ export function TerrorZoneConfiguration() { const handleDisableAll = useCallback(async () => { try { setIsSaving(true); - const newConfig: Record = {}; + const newConfig: Record = {}; zones.forEach((zone) => { newConfig[zone.id] = false; }); @@ -371,7 +371,7 @@ export function TerrorZoneConfiguration() { handleZoneToggle(zone.id, checked)} + onCheckedChange={(checked: boolean) => handleZoneToggle(zone.id, checked)} disabled={isSaving || !validationStatus.valid} />