This commit is contained in:
2026-10-18 04:40:55 +07:00
parent c36d3fd314
commit 41d3e8ac5e
12 changed files with 4105 additions and 157 deletions
+103 -144
View File
@@ -1,3 +1,5 @@
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
@@ -449,12 +451,9 @@ export class Online {
const version = this.planVersion
this.lastPlan = Date.now()
try {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan`,
undefined,
true,
AbortSignal.timeout(5000),
)
const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({
gameID: pkg.package.game.id,
})
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
} catch (error) {
if (generation === this.generation && version === this.planVersion)
@@ -472,13 +471,10 @@ export class Online {
const version = ++this.planVersion
this.planBusy++
try {
const result = await this.json(
await this.authorized(`/api/games/${pkg.package.game.id}/screen-plan`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(plan),
}),
)
const result = await this.client().SaveScreenPlan({
gameID: pkg.package.game.id,
...apiPlan(plan),
})
if (generation !== this.generation) throw new SessionChangedError()
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
} finally {
@@ -576,12 +572,10 @@ export class Online {
return
}
if (intent.action === 'step') {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan/step`,
{ currentStep: intent.index },
true,
AbortSignal.timeout(5000),
)
const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({
gameID: pkg.package.game.id,
currentStep: intent.index,
})
const confirmed = parsePlan(result.plan)
const shown = this.controller.state.catalog.steps[intent.index]
await this.acceptPlan(confirmed, pkg.key, generation, version)
@@ -599,29 +593,29 @@ export class Online {
}
} else if ('channel' in intent && intent.channel === 'music') {
const c = localState.channels.music
await this.api(
`/api/games/${pkg.package.game.id}/music`,
{
playlistID: c.playlistId ?? 0,
trackIndex: c.trackIndex,
positionMs: Math.max(0, Math.round(c.position * 1000)),
volume: Math.round(c.volume),
loop: c.loop,
playback: c.desired === 'playing' ? 2 : c.desired === 'paused' ? 3 : 1,
},
true,
AbortSignal.timeout(5000),
)
await this.client(true, AbortSignal.timeout(5000)).SetGameMusic({
gameID: pkg.package.game.id,
playlistID: c.playlistId ?? 0,
trackIndex: c.trackIndex,
positionMs: Math.max(0, Math.round(c.position * 1000)),
volume: Math.round(c.volume),
loop: c.loop,
playback:
c.desired === 'playing'
? 'MUSIC_PLAYBACK_PLAYING'
: c.desired === 'paused'
? 'MUSIC_PLAYBACK_PAUSED'
: 'MUSIC_PLAYBACK_STOPPED',
})
} else if (intent.action === 'mode' || intent.action === 'select') {
await this.api(
`/api/games/${pkg.package.game.id}/screens/mode`,
{ mode: localState.scene.mode },
true,
AbortSignal.timeout(5000),
)
await this.client(true, AbortSignal.timeout(5000)).SetScreenMode({
gameID: pkg.package.game.id,
mode: apiScreenMode(localState.scene.mode),
})
} else if (intent.action === 'text') {
const path = `/api/games/${pkg.package.game.id}/screen-plan`
const current = parsePlan((await this.api(path)).plan)
const current = parsePlan(
(await this.client().GetScreenPlan({ gameID: pkg.package.game.id })).plan,
)
if (
generation !== this.generation ||
session !== this.controller.state.session ||
@@ -637,13 +631,7 @@ export class Online {
timerLabel: '',
timerDurationSeconds: 0,
})
await this.json(
await this.authorized(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(current),
}),
)
await this.client().SaveScreenPlan({ gameID: pkg.package.game.id, ...apiPlan(current) })
}
if (
generation !== this.generation ||
@@ -651,7 +639,10 @@ export class Online {
version !== this.planVersion
)
return
const result = await this.api(`${path}/step`, { currentStep: index })
const result = await this.client().SetScreenPlanStep({
gameID: pkg.package.game.id,
currentStep: index,
})
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if (
generation !== this.generation ||
@@ -661,21 +652,18 @@ export class Online {
return
this.controller.markStep(index)
} else if (intent.action === 'timer-set') {
await this.api(
`/api/games/${pkg.package.game.id}/screens/timer`,
{ label: intent.label, durationSeconds: intent.durationSeconds },
true,
AbortSignal.timeout(5000),
)
await this.client(true, AbortSignal.timeout(5000)).SetScreenTimer({
gameID: pkg.package.game.id,
label: intent.label,
durationSeconds: intent.durationSeconds,
})
} else {
const index = this.controller.state.scene.stepIndex
if (index !== null && index !== this.plan.currentStep) {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan/step`,
{ currentStep: index },
true,
AbortSignal.timeout(5000),
)
const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({
gameID: pkg.package.game.id,
currentStep: index,
})
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if (
generation !== this.generation ||
@@ -699,16 +687,12 @@ export class Online {
}
if (!(intent.action in commands)) throw new Error('Некорректная команда плана')
expectedCommand = commands[intent.action as keyof typeof commands]
await this.api(
`/api/games/${pkg.package.game.id}/screens/command`,
{
command: expectedCommand,
positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0,
volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume,
},
true,
AbortSignal.timeout(5000),
)
await this.client(true, AbortSignal.timeout(5000)).SendScreenCommand({
gameID: pkg.package.game.id,
command: apiScreenCommand(expectedCommand),
positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0,
volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume,
})
}
if (
generation !== this.generation ||
@@ -719,12 +703,10 @@ export class Online {
this.planNotice = ''
// Ответ следующего опроса становится базой: собственная команда не применяется дважды.
if (this.screen) {
const result = await this.api(
'/api/screens/state',
{ screenID: this.screen.id, accessToken: this.screen.token },
false,
AbortSignal.timeout(5000),
)
const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({
screenID: this.screen.id,
accessToken: this.screen.token,
})
if (generation !== this.generation || session !== this.controller.state.session) return
const remote = parseRemote(result.state)
if (remote.gameId !== pkg.package.game.id) throw new Error('Привязка экрана изменилась')
@@ -748,12 +730,9 @@ export class Online {
needsMainSync = !isMusicCommand
}
if (needsMainSync && remote.command === 0) {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan`,
undefined,
true,
AbortSignal.timeout(5000),
)
const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({
gameID: pkg.package.game.id,
})
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if (
generation !== this.generation ||
@@ -1021,19 +1000,18 @@ export class Online {
if (this.refreshPromise) return this.refreshPromise
const c = this.credentials!
this.refreshPromise = (async () => {
const response = await this.raw(c.server, '/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: c.refresh }),
})
if (generation !== this.generation) throw new SessionChangedError()
if (response.status === 401) {
this.credentials = null
this.state.authenticated = false
await this.persistSecrets()
throw new ApiError(401)
let result
try {
result = await this.client(false, undefined, c.server).Refresh({ refreshToken: c.refresh })
} catch (error) {
if (generation !== this.generation) throw new SessionChangedError()
if (error instanceof ApiError && error.status === 401) {
this.credentials = null
this.state.authenticated = false
await this.persistSecrets()
}
throw error
}
const result = await this.json(response)
if (generation !== this.generation) throw new SessionChangedError()
this.credentials = {
...c,
@@ -1070,21 +1048,19 @@ export class Online {
}
return response
}
private async api(path: string, body?: object, authorized = true, signal?: AbortSignal) {
const init: RequestInit =
body === undefined
? { signal }
: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
}
return this.json(
authorized
? await this.authorized(path, init)
: await this.raw(this.state.server, path, init),
)
private client(authorized = true, signal?: AbortSignal, server = this.state.server) {
return createEveningDetectiveServerClient(async (request) => {
const init: RequestInit = {
method: request.method,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: request.body,
signal,
}
const path = `/${request.path}`
return this.json(
authorized ? await this.authorized(path, init) : await this.raw(server, path, init),
)
})
}
async act(input: unknown): Promise<OnlineState> {
const data = object(input)
@@ -1127,12 +1103,7 @@ export class Online {
this.state.games = []
await this.persistSecrets()
this.state.server = server
const response = await this.raw(server, '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const result = await this.json(response)
const result = await this.client(false, undefined, server).Login({ email, password })
this.credentials = {
server,
access: token(result.accessToken),
@@ -1140,7 +1111,7 @@ export class Online {
screen: null,
}
try {
const me = object((await this.api('/api/users/me')).user)
const me = object((await this.client().GetMe({})).user)
if (
!Array.isArray(me.roles) ||
!me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r)))
@@ -1261,17 +1232,13 @@ export class Online {
this.state.packagesRevision++
await this.saveSelection()
}
const result = await this.api('/api/screens', {}, false)
const result = await this.client(false).CreateScreenSession({})
const s: ScreenSession = {
id: String(integer(result.screenID, 1)),
token: token(result.accessToken),
gameId,
}
await this.api(`/api/screens/${s.id}/bind`, {
screenID: s.id,
accessToken: s.token,
gameID: s.gameId,
})
await this.client().BindScreen({ screenID: s.id, accessToken: s.token, gameID: s.gameId })
this.screen = s
this.state.screenId = s.id
this.state.connection = 'waiting'
@@ -1285,10 +1252,7 @@ export class Online {
case 'sound': {
const generation = this.generation
if (!this.screen) throw new Error('Сначала подключите управление')
await this.api(`/api/screens/${this.screen.id}/sound`, {
screenID: this.screen.id,
soundEnabled: true,
})
await this.client().SetScreenSound({ screenID: this.screen.id, soundEnabled: true })
if (generation === this.generation) this.state.soundEnabled = true
return
}
@@ -1299,7 +1263,7 @@ export class Online {
}
}
private async loadGames() {
const result = await this.api('/api/games')
const result = await this.client().GetGames({})
if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр')
this.state.games = ((result.games ?? []) as unknown[]).map((value) => {
const g = object(value)
@@ -1328,11 +1292,10 @@ export class Online {
this.resultsBusy = true
this.lastResults = Date.now()
try {
const result = await this.api(
'/api/screens/results',
{ screenID: s.id, accessToken: s.token },
false,
)
const result = await this.client(false).GetScreenResults({
screenID: s.id,
accessToken: s.token,
})
if (generation !== this.generation || key !== this.controller.state.catalog.key) return
if (!Array.isArray(result.questions ?? []) || !Array.isArray(result.teams ?? []))
throw new Error('Некорректные результаты')
@@ -1378,12 +1341,10 @@ export class Online {
const version = this.planVersion
this.pollBusy = true
try {
const result = await this.api(
'/api/screens/state',
{ screenID: s.id, accessToken: s.token },
false,
AbortSignal.timeout(5000),
)
const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({
screenID: s.id,
accessToken: s.token,
})
if (generation !== this.generation || version !== this.planVersion) return
let next = parseRemote(result.state)
if (next.gameId !== s.gameId) {
@@ -1458,12 +1419,10 @@ export class Online {
await this.synchronize()
return
}
const result = await this.api(
'/api/screens/music/advance',
{ screenID: this.screen.id, accessToken: this.screen.token },
false,
AbortSignal.timeout(5000),
)
const result = await this.client(false, AbortSignal.timeout(5000)).AdvanceScreenMusic({
screenID: this.screen.id,
accessToken: this.screen.token,
})
if (generation !== this.generation || report.session !== this.controller.state.session) return
const music = parseRemote({ music: result.state }).music
if (this.received) {