refactor: modularize launcher UI and native services with verified IO
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { deepStrictEqual, equal, match } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { gameReducer, initialGameState, installPercent } from './game.ts'
|
||||
|
||||
const profileId = 'aeronautics'
|
||||
const installing = gameReducer(initialGameState, { type: 'install', profileId })
|
||||
const launching = gameReducer(installing, { type: 'launch', profileId })
|
||||
|
||||
test('installation, running game and exit have distinct states', () => {
|
||||
const progressed = gameReducer(installing, { type: 'progress', progress: { stage: 'assets', currentBytes: 12, totalBytes: 24 } })
|
||||
equal(progressed.operation.phase, 'installing')
|
||||
const running = gameReducer(launching, { type: 'started', profileId })
|
||||
equal(running.operation.phase, 'running')
|
||||
deepStrictEqual(gameReducer(running, { type: 'exited', result: { profileId, exitCode: 0 } }), initialGameState)
|
||||
})
|
||||
|
||||
test('a fast child exit cannot be overwritten by a late launch acknowledgement', () => {
|
||||
const exited = gameReducer(launching, { type: 'exited', result: { profileId, exitCode: 1 } })
|
||||
const lateAcknowledgement = gameReducer(exited, { type: 'started', profileId })
|
||||
equal(lateAcknowledgement.operation.phase, 'idle')
|
||||
match(lateAcknowledgement.error ?? '', /кодом 1/)
|
||||
})
|
||||
|
||||
test('foreign exit events and late install progress cannot unlock a running game', () => {
|
||||
const running = gameReducer(launching, { type: 'started', profileId })
|
||||
equal(gameReducer(running, { type: 'exited', result: { profileId: 'other', exitCode: 0 } }), running)
|
||||
equal(gameReducer(running, { type: 'progress', progress: { stage: 'assets', currentBytes: 1, totalBytes: 1 } }), running)
|
||||
equal(gameReducer(running, { type: 'sync', profileId }), running)
|
||||
})
|
||||
|
||||
test('sync completion does not complete a different operation', () => {
|
||||
equal(gameReducer(installing, { type: 'synced', profileId }), installing)
|
||||
const syncing = gameReducer(initialGameState, { type: 'sync', profileId })
|
||||
equal(gameReducer(syncing, { type: 'synced', profileId: 'other' }), syncing)
|
||||
equal(gameReducer(syncing, { type: 'synced', profileId }), initialGameState)
|
||||
})
|
||||
|
||||
test('an operation failure releases the UI and a retry clears the error', () => {
|
||||
const failed = gameReducer(installing, { type: 'failed', error: 'Network failed' })
|
||||
equal(failed.operation.phase, 'idle')
|
||||
equal(failed.error, 'Network failed')
|
||||
equal(gameReducer(failed, { type: 'install', profileId }).error, null)
|
||||
})
|
||||
|
||||
test('percent is bounded and unknown or invalid totals stay indeterminate', () => {
|
||||
equal(installPercent(null), null)
|
||||
equal(installPercent({ stage: 'java', currentBytes: 1, totalBytes: 0 }), null)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: NaN, totalBytes: 10 }), null)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: 15, totalBytes: 10 }), 100)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: -5, totalBytes: 10 }), 0)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: 5, totalBytes: 20 }), 25)
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { GameExitedPayload, InstallProgressPayload } from '../types/launcher'
|
||||
|
||||
export type GameOperation =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'syncing' | 'launching' | 'running'; profileId: string }
|
||||
| { phase: 'installing'; profileId: string; progress: InstallProgressPayload | null }
|
||||
|
||||
export interface GameState {
|
||||
operation: GameOperation
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'sync'; profileId: string }
|
||||
| { type: 'synced'; profileId: string }
|
||||
| { type: 'install'; profileId: string }
|
||||
| { type: 'progress'; progress: InstallProgressPayload }
|
||||
| { type: 'launch'; profileId: string }
|
||||
| { type: 'started'; profileId: string }
|
||||
| { type: 'exited'; result: GameExitedPayload }
|
||||
| { type: 'failed'; error: string }
|
||||
|
||||
export const initialGameState: GameState = { operation: { phase: 'idle' }, error: null }
|
||||
|
||||
export function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
const operation = state.operation
|
||||
switch (action.type) {
|
||||
case 'sync':
|
||||
case 'install':
|
||||
if (operation.phase !== 'idle') return state
|
||||
return {
|
||||
operation: action.type === 'sync'
|
||||
? { phase: 'syncing', profileId: action.profileId }
|
||||
: { phase: 'installing', profileId: action.profileId, progress: null },
|
||||
error: null,
|
||||
}
|
||||
case 'synced':
|
||||
return operation.phase === 'syncing' && operation.profileId === action.profileId
|
||||
? initialGameState : state
|
||||
case 'progress':
|
||||
return operation.phase === 'installing'
|
||||
? { ...state, operation: { ...operation, progress: action.progress } } : state
|
||||
case 'launch':
|
||||
return operation.phase === 'installing' && operation.profileId === action.profileId
|
||||
? { ...state, operation: { phase: 'launching', profileId: action.profileId } } : state
|
||||
case 'started':
|
||||
// A fast-exiting child can emit game-exited before invoke resolves.
|
||||
return operation.phase === 'launching' && operation.profileId === action.profileId
|
||||
? { ...state, operation: { phase: 'running', profileId: action.profileId } } : state
|
||||
case 'exited':
|
||||
if ((operation.phase !== 'launching' && operation.phase !== 'running') ||
|
||||
operation.profileId !== action.result.profileId) return state
|
||||
return {
|
||||
operation: { phase: 'idle' },
|
||||
error: action.result.exitCode === 0 ? null
|
||||
: action.result.exitCode === null ? 'Игра завершилась без кода выхода. Проверьте журнал игры.'
|
||||
: `Игра завершилась с кодом ${action.result.exitCode}. Проверьте журнал игры.`,
|
||||
}
|
||||
case 'failed':
|
||||
return { operation: { phase: 'idle' }, error: action.error }
|
||||
}
|
||||
}
|
||||
|
||||
export const installStageLabels: Record<InstallProgressPayload['stage'], string> = {
|
||||
java: 'Готовим Java',
|
||||
neoforge: 'Устанавливаем NeoForge',
|
||||
libraries: 'Скачиваем библиотеки',
|
||||
assets: 'Скачиваем ресурсы игры',
|
||||
}
|
||||
|
||||
export function installPercent(progress: InstallProgressPayload | null): number | null {
|
||||
if (!progress || progress.totalBytes <= 0 || !Number.isFinite(progress.totalBytes) ||
|
||||
!Number.isFinite(progress.currentBytes)) return null
|
||||
return Math.max(0, Math.min(100, Math.round(progress.currentBytes / progress.totalBytes * 100)))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { equal } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { profilesReducer } from './profiles.ts'
|
||||
|
||||
test('a failed repair invalidates an old ready inspection and permits retry', () => {
|
||||
const profileId = 'aeronautics'
|
||||
const inspection = { root: '/profiles/aeronautics', managedFiles: 251, missingFiles: 0, mismatchedFiles: 0, upToDate: true }
|
||||
const ready = profilesReducer({}, { type: 'checked', profileId, inspection })
|
||||
equal(ready[profileId]?.inspection?.upToDate, true)
|
||||
const repairing = profilesReducer(ready, { type: 'check', profileId })
|
||||
equal(repairing[profileId]?.inspection, null)
|
||||
const failed = profilesReducer(repairing, { type: 'failed', profileId, error: 'Download interrupted' })
|
||||
equal(failed[profileId]?.status, 'error')
|
||||
equal(failed[profileId]?.inspection, null)
|
||||
const retrying = profilesReducer(failed, { type: 'check', profileId })
|
||||
const repaired = profilesReducer(retrying, { type: 'checked', profileId, inspection })
|
||||
equal(repaired[profileId]?.inspection?.upToDate, true)
|
||||
equal(repaired[profileId]?.error, null)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ProfileInspection } from '../types/launcher'
|
||||
|
||||
export type ProfileState =
|
||||
| { status: 'checking'; inspection: null; error: null }
|
||||
| { status: 'checked'; inspection: ProfileInspection; error: null }
|
||||
| { status: 'error'; inspection: null; error: string }
|
||||
|
||||
type ProfileAction =
|
||||
| { type: 'check'; profileId: string }
|
||||
| { type: 'checked'; profileId: string; inspection: ProfileInspection }
|
||||
| { type: 'failed'; profileId: string; error: string }
|
||||
|
||||
export function profilesReducer(
|
||||
profiles: Record<string, ProfileState>, action: ProfileAction,
|
||||
): Record<string, ProfileState> {
|
||||
const next: ProfileState = action.type === 'check'
|
||||
? { status: 'checking', inspection: null, error: null }
|
||||
: action.type === 'checked'
|
||||
? { status: 'checked', inspection: action.inspection, error: null }
|
||||
: { status: 'error', inspection: null, error: action.error }
|
||||
return { ...profiles, [action.profileId]: next }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { LauncherSettings } from '../types/launcher'
|
||||
|
||||
export const defaultSettings: LauncherSettings = {
|
||||
memoryMb: 6 * 1024,
|
||||
nickname: 'Emil',
|
||||
accountMode: 'offline',
|
||||
}
|
||||
|
||||
export const isValidNickname = (nickname: string) => /^[A-Za-z0-9_]{3,16}$/.test(nickname)
|
||||
Reference in New Issue
Block a user