Files
laude/src/main/games/registry.ts
2026-06-06 02:27:04 +07:00

181 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BrowserWindow } from 'electron'
import type { GameProvider, MatchEndPayload } from './provider'
import { Dota2Provider } from './dota2'
import { startGsiServer, stopGsiServer } from './gsi-server'
import { onLaunchOptionsApplied } from './steam-launch-options'
import { IPC } from '@shared/ipc'
import type {
ChallengeResult,
GameId,
GameStatus,
MatchSummary
} from '@shared/types'
import { STAT_LABELS } from '@shared/types'
import { getChallenges, getGamesEnabled } from '../store'
import { fireMatchSummary } from '../notifications'
import { log } from '../logger'
const providers: Record<GameId, GameProvider> = {
dota2: new Dota2Provider()
}
let running = false
async function onMatchEnd(
gameId: GameId,
payload: MatchEndPayload
): Promise<void> {
const provider = providers[gameId]
const allChallenges = getChallenges().filter((c) => c.gameId === gameId)
const enabledChallenges = allChallenges.filter((c) => c.enabled)
log.info(
`[games] match_end gameId=${gameId} stats=${JSON.stringify(
payload.stats
)} challenges=${enabledChallenges.length}/${allChallenges.length} (enabled/total)`
)
const results: ChallengeResult[] = []
for (const ch of enabledChallenges) {
const statValue = payload.stats[ch.stat] ?? 0
const reps = Math.round(statValue * ch.multiplier)
if (reps <= 0) {
log.debug(
`[games] skip challenge "${ch.name}": ${ch.stat}=${statValue} × ${ch.multiplier} = ${reps}`
)
continue
}
results.push({
challengeId: ch.id,
name: ch.name,
icon: ch.icon,
exerciseName: ch.exerciseName,
reps,
statValue,
statLabel: STAT_LABELS[ch.stat],
stat: ch.stat
})
}
if (results.length === 0) {
log.warn(
`[games] match_end produced no reps (no enabled challenges matched stats). ` +
`Enabled challenges: ${enabledChallenges.length}, stats keys: ${Object.keys(
payload.stats
).join(',')}`
)
return
}
log.info(
`[games] firing match summary: ${results.length} challenges, total reps ${results.reduce(
(s, r) => s + r.reps,
0
)}`
)
const summary: MatchSummary = {
gameId,
gameName: provider.displayName,
durationMs: payload.durationMs,
won: payload.won,
results
}
fireMatchSummary(summary)
}
export async function startGamesRegistry(): Promise<void> {
if (running) return
running = true
try {
await startGsiServer()
log.info('[games] GSI server started on port 4701')
} catch (err) {
running = false
log.error('[games] GSI server failed to start', err)
return
}
onLaunchOptionsApplied(() => {
// When Steam closed and we flushed queued ops, refresh statuses.
void listGamesStatus().then((games) => broadcastGames(games))
})
const enabled = getGamesEnabled()
for (const id of Object.keys(providers) as GameId[]) {
const provider = providers[id]
// Reconcile launch options on boot (handles cases where Steam
// overwrote our flag, or user removed it manually).
try {
await provider.reconcile?.()
} catch (err) {
log.error(`[games] reconcile failed for ${id}`, err)
}
if (!enabled[id]) continue
await provider.start((e) => {
if (e.type === 'match_end') void onMatchEnd(id, e.payload)
})
}
}
export async function stopGamesRegistry(): Promise<void> {
if (!running) return
running = false
for (const id of Object.keys(providers) as GameId[]) {
await providers[id].stop()
}
await stopGsiServer()
}
export async function listGamesStatus(): Promise<GameStatus[]> {
const enabled = getGamesEnabled()
const out: GameStatus[] = []
for (const id of Object.keys(providers) as GameId[]) {
const s = await providers[id].detect()
out.push({ ...s, enabled: !!enabled[id] })
}
return out
}
export async function installGame(id: GameId): Promise<GameStatus> {
const provider = providers[id]
await provider.install()
return { ...(await provider.detect()), enabled: !!getGamesEnabled()[id] }
}
export async function uninstallGame(id: GameId): Promise<GameStatus> {
const provider = providers[id]
await provider.uninstall()
await provider.stop()
return { ...(await provider.detect()), enabled: !!getGamesEnabled()[id] }
}
export async function toggleGame(id: GameId, enabled: boolean): Promise<void> {
const provider = providers[id]
if (enabled) {
await provider.start((e) => {
if (e.type === 'match_end') void onMatchEnd(id, e.payload)
})
} else {
await provider.stop()
}
}
export function broadcastGames(games: GameStatus[]): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtGamesChanged, games)
}
}
// Simulate a match-end for debugging (called from IPC in dev).
export function simulateMatchEnd(
id: GameId,
stats: Partial<Record<string, number>>
): void {
void onMatchEnd(id, {
durationMs: (stats.duration_min ?? 35) * 60_000,
won: stats.won === 1,
stats: stats as Partial<Record<import('@shared/types').GameStat, number>>
})
}
export function getProviders(): Record<GameId, GameProvider> {
return providers
}