181 lines
5.1 KiB
TypeScript
181 lines
5.1 KiB
TypeScript
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
|
||
}
|