Skip to content

Commit 474cb45

Browse files
authored
added config file (#21)
1 parent cf11792 commit 474cb45

6 files changed

Lines changed: 186 additions & 7 deletions

File tree

.env.e2e

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VITE_E2E=1

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ jobs:
3232
- name: Run tests
3333
run: npm run test
3434

35+
- name: Build
36+
run: npm run build
37+
3538
- name: Install Playwright browsers
3639
run: npx playwright install --with-deps chromium
3740

3841
- name: Run e2e tests
3942
run: npm run test:e2e
40-
41-
- name: Build
42-
run: npm run build

electron/main.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const MAIA_DOWNLOAD_BASE_URL = normalizeBaseUrl(
3030
const VIBECHESS_DIR_NAME = '.vibeChess';
3131
const ENGINE_DIR_NAME = 'engine';
3232
const MAIA_DIR_NAME = 'maia';
33+
const CONFIG_FILE_NAME = 'config.json';
3334
const STOCKFISH_JS = 'stockfish-17.1-lite-single-03e3232.js';
3435
const STOCKFISH_WASM = 'stockfish-17.1-lite-single-03e3232.wasm';
3536
const ZEROFISH_JS = 'zerofishEngine.js';
@@ -122,6 +123,7 @@ const ENGINE_ASSETS = LEGACY_ENGINE_DOWNLOAD_BASE_URL
122123
];
123124

124125
const getVibeChessDir = () => path.join(app.getPath('home'), VIBECHESS_DIR_NAME);
126+
const getConfigPath = () => path.join(getVibeChessDir(), CONFIG_FILE_NAME);
125127

126128
const buildEnginePaths = (baseDir, serverPort) => {
127129
if (serverPort) {
@@ -156,6 +158,26 @@ const fileExists = async (filePath) => {
156158
}
157159
};
158160

161+
const readConfigFile = async () => {
162+
const configPath = getConfigPath();
163+
try {
164+
const raw = await fs.promises.readFile(configPath, 'utf8');
165+
return JSON.parse(raw);
166+
} catch {
167+
return null;
168+
}
169+
};
170+
171+
const writeConfigFile = async (payload) => {
172+
const configPath = getConfigPath();
173+
await fs.promises.mkdir(getVibeChessDir(), { recursive: true });
174+
const tempPath = `${configPath}.tmp`;
175+
const json = JSON.stringify(payload ?? {}, null, 2);
176+
await fs.promises.writeFile(tempPath, json, 'utf8');
177+
await fs.promises.rename(tempPath, configPath);
178+
return true;
179+
};
180+
159181
const toNodeStream = (body) => {
160182
if (!body) return null;
161183
if (typeof body.pipe === 'function') return body;
@@ -208,6 +230,14 @@ const sendDownloadEvent = (webContentsSet, payload) => {
208230

209231
let activeDownload = null;
210232

233+
ipcMain.handle('config:read', async () => {
234+
return readConfigFile();
235+
});
236+
237+
ipcMain.handle('config:write', async (_event, payload) => {
238+
return writeConfigFile(payload);
239+
});
240+
211241
const ensureEngineAssets = async (webContentsSet, serverPort) => {
212242
const baseDir = getVibeChessDir();
213243
await fs.promises.mkdir(baseDir, { recursive: true });

playwright.config.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,10 @@ export default defineConfig({
1212
screenshot: 'only-on-failure',
1313
},
1414
webServer: {
15-
command: 'npx vite preview --host 127.0.0.1 --port 4173',
15+
command:
16+
'npx vite build --mode e2e && npx vite preview --host 127.0.0.1 --port 4173 --strictPort',
1617
url: 'http://127.0.0.1:4173',
1718
reuseExistingServer: !process.env.CI,
18-
env: {
19-
VITE_E2E: '1',
20-
},
2119
},
2220
projects: [
2321
{

src/App.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
type EngineAssets,
2727
type EngineDownloadEvent,
2828
} from './engine/engineAssets'
29+
import { loadPlayerConfig, savePlayerConfig, type PlayerConfig } from './playerConfig'
2930
import Settings, { BOARD_THEMES, type BoardThemeKey } from './Settings'
3031

3132
type Suggestion = {
@@ -175,6 +176,7 @@ function App() {
175176
const [takebackLimit, setTakebackLimit] = useState<number>(Infinity)
176177
const [takebacksUsed, setTakebacksUsed] = useState(0)
177178
const [allowEloChangeMidGame, setAllowEloChangeMidGame] = useState(false)
179+
const [configReady, setConfigReady] = useState(false)
178180

179181
// Click-to-move helper state
180182
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null)
@@ -200,6 +202,45 @@ function App() {
200202
}
201203
}, [ensureEngineAssets])
202204

205+
useEffect(() => {
206+
let cancelled = false
207+
208+
const loadConfig = async () => {
209+
try {
210+
const config = await loadPlayerConfig()
211+
if (cancelled || !config) return
212+
if (config.gameMode) setGameMode(config.gameMode)
213+
if (config.colorChoice) setColorChoice(config.colorChoice)
214+
if (config.elo !== undefined) setElo(config.elo)
215+
if (config.boardTheme) setBoardThemeKey(config.boardTheme)
216+
if (config.takebackLimit !== undefined) setTakebackLimit(config.takebackLimit)
217+
if (config.allowEloChangeMidGame !== undefined) {
218+
setAllowEloChangeMidGame(config.allowEloChangeMidGame)
219+
}
220+
} finally {
221+
if (!cancelled) setConfigReady(true)
222+
}
223+
}
224+
225+
void loadConfig()
226+
return () => {
227+
cancelled = true
228+
}
229+
}, [setAllowEloChangeMidGame, setBoardThemeKey, setColorChoice, setElo, setGameMode, setTakebackLimit])
230+
231+
useEffect(() => {
232+
if (!configReady) return
233+
const payload: PlayerConfig = {
234+
gameMode,
235+
colorChoice,
236+
elo,
237+
boardTheme: boardThemeKey,
238+
takebackLimit,
239+
allowEloChangeMidGame,
240+
}
241+
void savePlayerConfig(payload)
242+
}, [allowEloChangeMidGame, boardThemeKey, colorChoice, configReady, elo, gameMode, takebackLimit])
243+
203244
const sendEngine = useCallback((cmd: string) => {
204245
const worker = analysisWorkerRef.current
205246
if (!worker) return

src/playerConfig.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { BoardThemeKey } from './Settings'
2+
import type { ColorChoice, GameMode } from './chess/types'
3+
import { snapMaiaElo, type MaiaElo } from './engine/maiaEngine'
4+
5+
export type PlayerConfig = {
6+
gameMode: GameMode
7+
colorChoice: ColorChoice
8+
elo: MaiaElo
9+
boardTheme: BoardThemeKey
10+
takebackLimit: number
11+
allowEloChangeMidGame: boolean
12+
}
13+
14+
type StoredPlayerConfig = Omit<PlayerConfig, 'takebackLimit'> & {
15+
takebackLimit: number | null
16+
}
17+
18+
const LOCAL_STORAGE_KEY = 'vibeChess.config'
19+
20+
const getIpcRenderer = () => {
21+
if (typeof window === 'undefined') return null
22+
const anyWindow = window as typeof window & { require?: (module: string) => unknown }
23+
if (!anyWindow?.process?.versions?.electron || !anyWindow.require) return null
24+
try {
25+
return anyWindow.require('electron').ipcRenderer as {
26+
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>
27+
}
28+
} catch {
29+
return null
30+
}
31+
}
32+
33+
const isGameMode = (value: unknown): value is GameMode =>
34+
value === 'vs-maia' || value === '1v1'
35+
36+
const isColorChoice = (value: unknown): value is ColorChoice =>
37+
value === 'white' || value === 'black' || value === 'random'
38+
39+
const isBoardThemeKey = (value: unknown): value is BoardThemeKey =>
40+
value === 'green' || value === 'brown' || value === 'blue' || value === 'gray'
41+
42+
const normalizeConfig = (value: unknown): Partial<PlayerConfig> | null => {
43+
if (!value || typeof value !== 'object') return null
44+
const data = value as Record<string, unknown>
45+
const result: Partial<PlayerConfig> = {}
46+
47+
if (isGameMode(data.gameMode)) result.gameMode = data.gameMode
48+
if (isColorChoice(data.colorChoice)) result.colorChoice = data.colorChoice
49+
if (typeof data.elo === 'number' && Number.isFinite(data.elo)) {
50+
result.elo = snapMaiaElo(data.elo)
51+
}
52+
if (isBoardThemeKey(data.boardTheme)) result.boardTheme = data.boardTheme
53+
54+
if (data.takebackLimit === null) {
55+
result.takebackLimit = Infinity
56+
} else if (typeof data.takebackLimit === 'number' && Number.isFinite(data.takebackLimit)) {
57+
result.takebackLimit = Math.max(0, Math.floor(data.takebackLimit))
58+
}
59+
60+
if (typeof data.allowEloChangeMidGame === 'boolean') {
61+
result.allowEloChangeMidGame = data.allowEloChangeMidGame
62+
}
63+
64+
return result
65+
}
66+
67+
const toStoredConfig = (config: PlayerConfig): StoredPlayerConfig => ({
68+
...config,
69+
takebackLimit:
70+
config.takebackLimit === Infinity
71+
? null
72+
: Math.max(0, Math.floor(config.takebackLimit)),
73+
})
74+
75+
export const loadPlayerConfig = async (): Promise<Partial<PlayerConfig> | null> => {
76+
const ipc = getIpcRenderer()
77+
if (ipc) {
78+
try {
79+
const data = await ipc.invoke('config:read')
80+
return normalizeConfig(data)
81+
} catch {
82+
return null
83+
}
84+
}
85+
86+
try {
87+
const raw = localStorage.getItem(LOCAL_STORAGE_KEY)
88+
if (!raw) return null
89+
return normalizeConfig(JSON.parse(raw))
90+
} catch {
91+
return null
92+
}
93+
}
94+
95+
export const savePlayerConfig = async (config: PlayerConfig): Promise<void> => {
96+
const payload: StoredPlayerConfig = toStoredConfig(config)
97+
98+
const ipc = getIpcRenderer()
99+
if (ipc) {
100+
await ipc.invoke('config:write', payload)
101+
return
102+
}
103+
104+
try {
105+
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(payload))
106+
} catch {
107+
// Ignore storage failures in browser mode.
108+
}
109+
}

0 commit comments

Comments
 (0)