Initial commit
This commit is contained in:
218
src/main/games/dota2.ts
Normal file
218
src/main/games/dota2.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user