Initial commit

This commit is contained in:
AnRil
2026-05-16 13:43:29 +07:00
commit 688a86b611
208 changed files with 44350 additions and 0 deletions

218
src/main/games/dota2.ts Normal file
View File

@@ -0,0 +1,218 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { randomBytes } from 'node:crypto'
import { app } from 'electron'
import type { GameProvider, ProviderEventHandler } from './provider'
import { findGameInstall } from './steam'
import { registerGsiRoute, getGsiBaseUrl, getGsiPort } from './gsi-server'
import {
ensureLaunchOption,
ensureLaunchOptionRemoved,
isLaunchOptionPresent,
isSteamRunning
} from './steam-launch-options'
import type { GameId, GameStatus, LaunchOptionStatus } from '@shared/types'
const APP_ID = '570'
const INSTALL_DIR = 'dota 2 beta'
const CFG_NAME = 'gamestate_integration_exercise_reminder.cfg'
const ROUTE = '/dota2'
const LAUNCH_OPTION = '-gamestateintegration'
type DotaGsi = {
provider?: { name?: string }
map?: {
game_state?: string
win_team?: 'radiant' | 'dire' | 'none'
game_time?: number
clock_time?: number
}
player?: {
kills?: number
deaths?: number
assists?: number
last_hits?: number
denies?: number
team_name?: 'radiant' | 'dire'
steamid?: string
}
}
function tokenStorePath(): string {
return join(app.getPath('userData'), 'dota2-gsi-token.txt')
}
function getOrCreateToken(): string {
const p = tokenStorePath()
if (existsSync(p)) {
const v = readFileSync(p, 'utf-8').trim()
if (v) return v
}
const token = randomBytes(16).toString('hex')
writeFileSync(p, token, 'utf-8')
return token
}
function cfgDir(installPath: string): string {
return join(installPath, 'game', 'dota', 'cfg', 'gamestate_integration')
}
function cfgPath(installPath: string): string {
return join(cfgDir(installPath), CFG_NAME)
}
function buildCfg(token: string): string {
const uri = `${getGsiBaseUrl()}${ROUTE}`
return `"Exercise Reminder Integration"
{
"uri" "${uri}"
"timeout" "5.0"
"buffer" "0.1"
"throttle" "0.5"
"heartbeat" "10.0"
"auth"
{
"token" "${token}"
}
"data"
{
"provider" "1"
"map" "1"
"player" "1"
"hero" "1"
"abilities" "0"
"items" "0"
"events" "0"
"buildings" "0"
"league" "0"
"draft" "0"
"wearables" "0"
}
}
`
}
export class Dota2Provider implements GameProvider {
readonly id: GameId = 'dota2'
readonly displayName = 'Dota 2'
private installPath: string | undefined
private unregister: (() => void) | undefined
private emit: ProviderEventHandler | undefined
private prevState: string | undefined
private latest: DotaGsi | undefined
private lastMatchEndAt = 0
private token: string = getOrCreateToken()
async detect(): Promise<GameStatus> {
const path = await findGameInstall(APP_ID, INSTALL_DIR)
this.installPath = path
const integrationActive = !!path && existsSync(cfgPath(path))
let launchOptionStatus: LaunchOptionStatus = 'not_needed'
let steamRunning: boolean | undefined
if (integrationActive) {
const present = await isLaunchOptionPresent(APP_ID, LAUNCH_OPTION)
if (present) launchOptionStatus = 'applied'
else {
steamRunning = await isSteamRunning()
launchOptionStatus = steamRunning ? 'queued' : 'queued'
}
}
return {
id: this.id,
name: this.displayName,
installed: !!path,
installPath: path,
integrationActive,
launchOption: LAUNCH_OPTION,
launchOptionStatus,
steamRunning,
enabled: false
}
}
async install(): Promise<void> {
if (!this.installPath) {
const status = await this.detect()
if (!status.installPath) throw new Error('Dota 2 не найдена в Steam-библиотеках')
}
const dir = cfgDir(this.installPath!)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(cfgPath(this.installPath!), buildCfg(this.token), 'utf-8')
await ensureLaunchOption(APP_ID, LAUNCH_OPTION)
}
async uninstall(): Promise<void> {
await ensureLaunchOptionRemoved(APP_ID, LAUNCH_OPTION)
if (!this.installPath) return
const p = cfgPath(this.installPath)
if (existsSync(p)) unlinkSync(p)
}
async reconcile(): Promise<void> {
if (!this.installPath) await this.detect()
if (!this.installPath || !existsSync(cfgPath(this.installPath))) return
await ensureLaunchOption(APP_ID, LAUNCH_OPTION)
}
async start(emit: ProviderEventHandler): Promise<void> {
this.emit = emit
this.unregister = registerGsiRoute(ROUTE, (payload) => this.handle(payload as DotaGsi))
}
async stop(): Promise<void> {
this.unregister?.()
this.unregister = undefined
this.emit = undefined
this.prevState = undefined
this.latest = undefined
}
private handle(g: DotaGsi): void {
// Track latest snapshot so we have stats when the transition fires.
if (g.player || g.map) this.latest = { ...this.latest, ...g, player: { ...this.latest?.player, ...g.player }, map: { ...this.latest?.map, ...g.map } }
const state = g.map?.game_state ?? this.latest?.map?.game_state
if (!state) return
const prev = this.prevState
this.prevState = state
if (prev && prev !== state && state === 'DOTA_GAMERULES_STATE_POST_GAME') {
// De-dupe: Dota can fire POST_GAME repeatedly while the scoreboard is open.
const now = Date.now()
if (now - this.lastMatchEndAt < 30_000) return
this.lastMatchEndAt = now
const p = this.latest?.player ?? {}
const m = this.latest?.map ?? {}
const playerTeam = p.team_name
const winner = m.win_team
const won =
winner === 'radiant' || winner === 'dire'
? playerTeam === winner
: undefined
const durationMs = (m.game_time ?? m.clock_time ?? 0) * 1000
this.emit?.({
type: 'match_end',
payload: {
durationMs,
won,
stats: {
deaths: p.deaths ?? 0,
kills: p.kills ?? 0,
assists: p.assists ?? 0,
last_hits: p.last_hits ?? 0,
denies: p.denies ?? 0,
duration_min: Math.floor(durationMs / 60_000)
}
}
})
}
}
}
export function dota2DebugInfo(): { port: number; route: string } {
return { port: getGsiPort(), route: ROUTE }
}