mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Revert gameplay-specific review workflows (#2962)
This commit is contained in:
@@ -1,436 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
analyzeGameplaySession,
|
||||
askGameplaySession,
|
||||
draftGameplayClipMetadata,
|
||||
type GameplayReviewAnalysis,
|
||||
type GameplayReviewSession,
|
||||
listGameplaySessions,
|
||||
prepareGameplayFrames,
|
||||
registerGameplaySession,
|
||||
saveGameplayPreset,
|
||||
} from '../../services/gameplayReviewService';
|
||||
import { GameplayReviewWorkspace } from './GameplayReviewWorkspace';
|
||||
|
||||
vi.mock('../../services/gameplayReviewService', () => ({
|
||||
analyzeGameplaySession: vi.fn(),
|
||||
askGameplaySession: vi.fn(),
|
||||
draftGameplayClipMetadata: vi.fn(),
|
||||
flattenClipCandidates: vi.fn((session: any) => session?.analysis?.clip_candidates ?? []),
|
||||
flattenDrafts: vi.fn((session: any) => session?.analysis?.draft_metadata ?? []),
|
||||
flattenHighlights: vi.fn((session: any) => session?.analysis?.highlights ?? []),
|
||||
formatSpoilerMode: vi.fn((mode: string) =>
|
||||
mode === 'full' ? 'Full spoilers' : 'Light spoilers'
|
||||
),
|
||||
listGameplaySessions: vi.fn(),
|
||||
normalizeGameplayError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error)
|
||||
),
|
||||
prepareGameplayFrames: vi.fn(),
|
||||
registerGameplaySession: vi.fn(),
|
||||
saveGameplayPreset: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedListGameplaySessions = vi.mocked(listGameplaySessions);
|
||||
const mockedPrepareGameplayFrames = vi.mocked(prepareGameplayFrames);
|
||||
const mockedRegisterGameplaySession = vi.mocked(registerGameplaySession);
|
||||
const mockedAnalyzeGameplaySession = vi.mocked(analyzeGameplaySession);
|
||||
const mockedAskGameplaySession = vi.mocked(askGameplaySession);
|
||||
const mockedDraftGameplayClipMetadata = vi.mocked(draftGameplayClipMetadata);
|
||||
const mockedSaveGameplayPreset = vi.mocked(saveGameplayPreset);
|
||||
|
||||
describe('GameplayReviewWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
mockedListGameplaySessions.mockResolvedValue([]);
|
||||
mockedPrepareGameplayFrames.mockReset();
|
||||
mockedRegisterGameplaySession.mockReset();
|
||||
mockedAnalyzeGameplaySession.mockReset();
|
||||
mockedAskGameplaySession.mockReset();
|
||||
mockedDraftGameplayClipMetadata.mockReset();
|
||||
mockedSaveGameplayPreset.mockReset();
|
||||
});
|
||||
|
||||
it('imports selected frames, analyzes the session, and asks follow-up questions', async () => {
|
||||
mockedPrepareGameplayFrames.mockResolvedValue([
|
||||
{
|
||||
source_name: 'frame-1.png',
|
||||
file_name: 'frame-1.png',
|
||||
image_ref: 'data:image/png;base64,AAA',
|
||||
captured_at_ms: 123,
|
||||
},
|
||||
]);
|
||||
mockedRegisterGameplaySession.mockResolvedValue({
|
||||
session_id: 'gameplay-apex-1',
|
||||
game_id: 'Apex Legends',
|
||||
session_title: 'Ranked climb',
|
||||
source_label: '/recordings/apex',
|
||||
spoiler_mode: 'light',
|
||||
preset_id: 'Apex preset',
|
||||
imported_at_ms: 1000,
|
||||
analyzed_at_ms: null,
|
||||
frames: [
|
||||
{ file_name: 'frame-1.png', image_ref: 'data:image/png;base64,AAA', captured_at_ms: 123 },
|
||||
],
|
||||
analysis: null,
|
||||
});
|
||||
mockedAnalyzeGameplaySession.mockResolvedValue({
|
||||
session_id: 'gameplay-apex-1',
|
||||
game_id: 'Apex Legends',
|
||||
session_title: 'Ranked climb',
|
||||
source_label: '/recordings/apex',
|
||||
spoiler_mode: 'light',
|
||||
preset_id: 'Apex preset',
|
||||
imported_at_ms: 1000,
|
||||
analyzed_at_ms: 2000,
|
||||
frames: [
|
||||
{ file_name: 'frame-1.png', image_ref: 'data:image/png;base64,AAA', captured_at_ms: 123 },
|
||||
],
|
||||
analysis: {
|
||||
recap: 'Gameplay recap',
|
||||
highlights: [
|
||||
{
|
||||
id: 'h1',
|
||||
frame_index: 0,
|
||||
captured_at_ms: 123,
|
||||
title: 'Highlight: clutch fight',
|
||||
rationale: 'Clean finish',
|
||||
confidence: 0.93,
|
||||
kind: 'highlight',
|
||||
},
|
||||
],
|
||||
clip_candidates: [
|
||||
{
|
||||
id: 'c1',
|
||||
frame_index: 0,
|
||||
start_label: 'frame-1.png',
|
||||
end_label: 'frame-1.png',
|
||||
rationale: 'Clean finish',
|
||||
confidence: 0.93,
|
||||
},
|
||||
],
|
||||
draft_metadata: [
|
||||
{
|
||||
platform: 'twitch',
|
||||
title: 'Apex Legends — Highlight: clutch fight (twitch)',
|
||||
description: 'Session: Ranked climb',
|
||||
tags: ['apex-legends'],
|
||||
},
|
||||
],
|
||||
follow_up_questions: ['What was the turning point?'],
|
||||
spoiler_note: null,
|
||||
},
|
||||
});
|
||||
mockedAskGameplaySession.mockResolvedValue({
|
||||
answer: 'Best clip candidate for Ranked climb: Highlight: clutch fight — Clean finish',
|
||||
matched_highlights: [],
|
||||
suggested_follow_up: ['What was the turning point?'],
|
||||
});
|
||||
mockedDraftGameplayClipMetadata.mockResolvedValue([
|
||||
{
|
||||
platform: 'twitch',
|
||||
title: 'Apex Legends — Highlight: clutch fight (twitch)',
|
||||
description: 'Session: Ranked climb',
|
||||
tags: ['apex-legends'],
|
||||
},
|
||||
]);
|
||||
mockedSaveGameplayPreset.mockResolvedValue({});
|
||||
|
||||
const { container } = render(<GameplayReviewWorkspace />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('No saved sessions yet.')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Apex Legends'), {
|
||||
target: { value: 'Apex Legends' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Ranked climb on Friday night'), {
|
||||
target: { value: 'Ranked climb' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('/recordings/apex/night-01'), {
|
||||
target: { value: '/recordings/apex' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Apex coaching preset'), {
|
||||
target: { value: 'Apex preset' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('aim, positioning, fight selection'), {
|
||||
target: { value: 'aim, positioning' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('What should the reviewer pay attention to?'), {
|
||||
target: { value: 'Play clean and keep it spoiler-safe.' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('twitch,kick,youtube'), {
|
||||
target: { value: 'twitch,kick' },
|
||||
});
|
||||
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(fileInput).not.toBeNull();
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'frame-1.png', { type: 'image/png' });
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: 'session/frame-1.png' });
|
||||
fireEvent.change(fileInput as HTMLInputElement, { target: { files: [file] } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /import and analyze/i }));
|
||||
|
||||
await waitFor(() => expect(mockedPrepareGameplayFrames).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText(/Gameplay recap/)).toBeInTheDocument());
|
||||
expect(
|
||||
screen.getByText('Highlight: clutch fight', { selector: 'div.font-medium.text-stone-900' })
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /ask session/i }));
|
||||
await waitFor(() => expect(mockedAskGameplaySession).toHaveBeenCalled());
|
||||
expect(screen.getByText(/Best clip candidate/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const BASE_ANALYSIS: GameplayReviewAnalysis = {
|
||||
recap: 'Solid session recap',
|
||||
highlights: [
|
||||
{
|
||||
id: 'h1',
|
||||
frame_index: 0,
|
||||
captured_at_ms: 123,
|
||||
title: 'Clutch fight',
|
||||
rationale: 'Clean finish',
|
||||
confidence: 0.9,
|
||||
kind: 'highlight',
|
||||
},
|
||||
],
|
||||
clip_candidates: [
|
||||
{
|
||||
id: 'c1',
|
||||
frame_index: 0,
|
||||
start_label: 'frame-1.png',
|
||||
end_label: 'frame-2.png',
|
||||
rationale: 'Great pacing',
|
||||
confidence: 0.8,
|
||||
},
|
||||
],
|
||||
draft_metadata: [
|
||||
{ platform: 'twitch', title: 'Clip title', description: 'Clip desc', tags: ['apex'] },
|
||||
],
|
||||
follow_up_questions: ['What was the turning point?'],
|
||||
spoiler_note: 'Endgame results hidden.',
|
||||
};
|
||||
|
||||
function analyzedSession(overrides: Partial<GameplayReviewSession> = {}): GameplayReviewSession {
|
||||
return {
|
||||
session_id: 'session-1',
|
||||
game_id: 'Apex Legends',
|
||||
session_title: 'Ranked climb',
|
||||
source_label: null,
|
||||
spoiler_mode: 'full',
|
||||
preset_id: null,
|
||||
imported_at_ms: 1000,
|
||||
analyzed_at_ms: 2000,
|
||||
frames: [
|
||||
{ file_name: 'frame-1.png', image_ref: 'data:image/png;base64,AAA', captured_at_ms: 123 },
|
||||
],
|
||||
analysis: BASE_ANALYSIS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function selectFile(container: HTMLElement) {
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'frame-1.png', { type: 'image/png' });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
}
|
||||
|
||||
it('surfaces an error when the initial session fetch fails', async () => {
|
||||
mockedListGameplaySessions.mockRejectedValueOnce(new Error('list failed'));
|
||||
|
||||
render(<GameplayReviewWorkspace />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('list failed')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('validates required fields before importing', async () => {
|
||||
render(<GameplayReviewWorkspace />);
|
||||
await waitFor(() => expect(screen.getByText('No saved sessions yet.')).toBeInTheDocument());
|
||||
|
||||
const importButton = screen.getByRole('button', { name: /import and analyze/i });
|
||||
|
||||
fireEvent.click(importButton);
|
||||
expect(screen.getByText('Game name is required.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Apex Legends'), { target: { value: 'Apex' } });
|
||||
fireEvent.click(importButton);
|
||||
expect(screen.getByText('Session title is required.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Ranked climb on Friday night'), {
|
||||
target: { value: 'Ranked' },
|
||||
});
|
||||
fireEvent.click(importButton);
|
||||
expect(
|
||||
screen.getByText('Choose a folder or a set of keyframe images first.')
|
||||
).toBeInTheDocument();
|
||||
expect(mockedPrepareGameplayFrames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('errors when no image frames are found and skips preset save without a preset name', async () => {
|
||||
mockedPrepareGameplayFrames.mockResolvedValue([]);
|
||||
|
||||
const { container } = render(<GameplayReviewWorkspace />);
|
||||
await waitFor(() => expect(screen.getByText('No saved sessions yet.')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Apex Legends'), { target: { value: 'Apex' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Ranked climb on Friday night'), {
|
||||
target: { value: 'Ranked' },
|
||||
});
|
||||
selectFile(container);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /import and analyze/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('No image frames were found in that folder.')).toBeInTheDocument()
|
||||
);
|
||||
expect(mockedSaveGameplayPreset).not.toHaveBeenCalled();
|
||||
expect(mockedRegisterGameplaySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a toast and inline error when registration fails', async () => {
|
||||
mockedPrepareGameplayFrames.mockResolvedValue([
|
||||
{
|
||||
source_name: 'frame-1.png',
|
||||
file_name: 'frame-1.png',
|
||||
image_ref: 'data:image/png;base64,AAA',
|
||||
captured_at_ms: 123,
|
||||
},
|
||||
]);
|
||||
mockedRegisterGameplaySession.mockRejectedValue(new Error('register boom'));
|
||||
const onToast = vi.fn();
|
||||
|
||||
const { container } = render(<GameplayReviewWorkspace onToast={onToast} />);
|
||||
await waitFor(() => expect(screen.getByText('No saved sessions yet.')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Apex Legends'), { target: { value: 'Apex' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Ranked climb on Friday night'), {
|
||||
target: { value: 'Ranked' },
|
||||
});
|
||||
selectFile(container);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /import and analyze/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('register boom')).toBeInTheDocument());
|
||||
expect(onToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error', title: 'Gameplay review failed' })
|
||||
);
|
||||
});
|
||||
|
||||
it('renders an analyzed session from history with highlights, clips, drafts, and follow-ups', async () => {
|
||||
mockedListGameplaySessions.mockResolvedValue([analyzedSession()]);
|
||||
|
||||
render(<GameplayReviewWorkspace />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Solid session recap')).toBeInTheDocument());
|
||||
expect(screen.getByText('Endgame results hidden.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clean finish')).toBeInTheDocument();
|
||||
expect(screen.getByText(/through frame-2.png/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Clip title')).toBeInTheDocument();
|
||||
expect(screen.getByText('#apex')).toBeInTheDocument();
|
||||
|
||||
// Clicking a follow-up question seeds the question input.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'What was the turning point?' }));
|
||||
expect(
|
||||
(screen.getByPlaceholderText('What were my best fights?') as HTMLInputElement).value
|
||||
).toBe('What was the turning point?');
|
||||
|
||||
// Refresh history re-fetches sessions.
|
||||
fireEvent.click(screen.getByRole('button', { name: /refresh history/i }));
|
||||
await waitFor(() => expect(mockedListGameplaySessions).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('shows empty states when the active session has no analysis', async () => {
|
||||
mockedListGameplaySessions.mockResolvedValue([analyzedSession({ analysis: null })]);
|
||||
|
||||
render(<GameplayReviewWorkspace />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('No highlights detected yet.')).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByText('No clip candidates yet.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Draft metadata will appear after analysis.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes clip metadata for the active session', async () => {
|
||||
mockedListGameplaySessions.mockResolvedValue([analyzedSession()]);
|
||||
mockedDraftGameplayClipMetadata.mockResolvedValue([
|
||||
{ platform: 'kick', title: 'Refreshed title', description: 'New desc', tags: ['fresh'] },
|
||||
]);
|
||||
|
||||
render(<GameplayReviewWorkspace />);
|
||||
await waitFor(() => expect(screen.getByText('Clip title')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /refresh clip metadata/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Refreshed title')).toBeInTheDocument());
|
||||
expect(mockedDraftGameplayClipMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ session_id: 'session-1', highlight_id: 'h1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces an error when refreshing clip metadata fails', async () => {
|
||||
mockedListGameplaySessions.mockResolvedValue([analyzedSession()]);
|
||||
mockedDraftGameplayClipMetadata.mockRejectedValue(new Error('draft boom'));
|
||||
|
||||
render(<GameplayReviewWorkspace />);
|
||||
await waitFor(() => expect(screen.getByText('Clip title')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /refresh clip metadata/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('draft boom')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('surfaces an error when asking the session fails', async () => {
|
||||
mockedListGameplaySessions.mockResolvedValue([analyzedSession()]);
|
||||
mockedAskGameplaySession.mockRejectedValue(new Error('ask boom'));
|
||||
|
||||
render(<GameplayReviewWorkspace />);
|
||||
await waitFor(() => expect(screen.getByText('Solid session recap')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /ask session/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ask boom')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('updates spoiler mode, audio feedback, and the question input', async () => {
|
||||
mockedListGameplaySessions.mockResolvedValue([analyzedSession()]);
|
||||
|
||||
const { container } = render(<GameplayReviewWorkspace />);
|
||||
await waitFor(() => expect(screen.getByText('Solid session recap')).toBeInTheDocument());
|
||||
|
||||
const spoilerSelect = container.querySelector('select') as HTMLSelectElement;
|
||||
fireEvent.change(spoilerSelect, { target: { value: 'off' } });
|
||||
expect(spoilerSelect.value).toBe('off');
|
||||
|
||||
const audioCheckbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(audioCheckbox);
|
||||
expect(audioCheckbox.checked).toBe(true);
|
||||
|
||||
const questionInput = screen.getByPlaceholderText(
|
||||
'What were my best fights?'
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(questionInput, { target: { value: 'Where did I lose tempo?' } });
|
||||
expect(questionInput.value).toBe('Where did I lose tempo?');
|
||||
|
||||
// The hidden file picker is triggered via the visible button.
|
||||
fireEvent.click(screen.getByRole('button', { name: /choose folder \/ frames/i }));
|
||||
});
|
||||
|
||||
it('switches the active session when picking one from history', async () => {
|
||||
const first = analyzedSession();
|
||||
const second = analyzedSession({
|
||||
session_id: 'session-2',
|
||||
session_title: 'Second session',
|
||||
analysis: { ...BASE_ANALYSIS, recap: 'Second recap' },
|
||||
});
|
||||
mockedListGameplaySessions.mockResolvedValue([first, second]);
|
||||
|
||||
render(<GameplayReviewWorkspace />);
|
||||
await waitFor(() => expect(screen.getByText('Solid session recap')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Second session/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Second recap')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -1,598 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
analyzeGameplaySession,
|
||||
askGameplaySession,
|
||||
draftGameplayClipMetadata,
|
||||
flattenClipCandidates,
|
||||
flattenDrafts,
|
||||
flattenHighlights,
|
||||
formatSpoilerMode,
|
||||
type GameplayFrameInput,
|
||||
type GameplayReviewSession,
|
||||
listGameplaySessions,
|
||||
normalizeGameplayError,
|
||||
prepareGameplayFrames,
|
||||
registerGameplaySession,
|
||||
saveGameplayPreset,
|
||||
type SpoilerMode,
|
||||
} from '../../services/gameplayReviewService';
|
||||
import type { ToastNotification } from '../../types/intelligence';
|
||||
|
||||
interface GameplayReviewWorkspaceProps {
|
||||
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
|
||||
}
|
||||
|
||||
interface ImportState {
|
||||
gameId: string;
|
||||
sessionTitle: string;
|
||||
sourceLabel: string;
|
||||
spoilerMode: SpoilerMode;
|
||||
presetName: string;
|
||||
coachingFocus: string;
|
||||
notes: string;
|
||||
audioFeedback: boolean;
|
||||
platforms: string;
|
||||
}
|
||||
|
||||
const INITIAL_IMPORT_STATE: ImportState = {
|
||||
gameId: '',
|
||||
sessionTitle: '',
|
||||
sourceLabel: '',
|
||||
spoilerMode: 'light',
|
||||
presetName: '',
|
||||
coachingFocus: '',
|
||||
notes: '',
|
||||
audioFeedback: false,
|
||||
platforms: 'twitch,kick,youtube',
|
||||
};
|
||||
|
||||
const DIRECTORY_INPUT_PROPS: Record<string, string> = { webkitdirectory: '' };
|
||||
|
||||
function formatTimestamp(ms?: number | null): string {
|
||||
if (!ms) return 'n/a';
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
function splitPlatforms(value: string): string[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function GameplayReviewWorkspace({ onToast }: GameplayReviewWorkspaceProps) {
|
||||
const { t } = useT();
|
||||
const [form, setForm] = useState(INITIAL_IMPORT_STATE);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [recentSessions, setRecentSessions] = useState<GameplayReviewSession[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<GameplayReviewSession | null>(null);
|
||||
const [question, setQuestion] = useState('What were my best moments?');
|
||||
const [questionAnswer, setQuestionAnswer] = useState<string>('');
|
||||
const [questionBusy, setQuestionBusy] = useState(false);
|
||||
const [importBusy, setImportBusy] = useState(false);
|
||||
const [analysisBusy, setAnalysisBusy] = useState(false);
|
||||
const [draftBusy, setDraftBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
try {
|
||||
const sessions = await listGameplaySessions();
|
||||
setRecentSessions(sessions);
|
||||
setActiveSession(currentSession => currentSession ?? sessions[0] ?? null);
|
||||
} catch (err) {
|
||||
setError(normalizeGameplayError(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSessions();
|
||||
}, [refreshSessions]);
|
||||
|
||||
const activeHighlights = useMemo(() => flattenHighlights(activeSession), [activeSession]);
|
||||
const activeDrafts = useMemo(() => flattenDrafts(activeSession), [activeSession]);
|
||||
const activeClips = useMemo(() => flattenClipCandidates(activeSession), [activeSession]);
|
||||
|
||||
const handleSelectFiles = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFilesChanged = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
setSelectedFiles(files);
|
||||
}, []);
|
||||
|
||||
const handleImportSession = useCallback(async () => {
|
||||
setError(null);
|
||||
if (!form.gameId.trim()) {
|
||||
setError('Game name is required.');
|
||||
return;
|
||||
}
|
||||
if (!form.sessionTitle.trim()) {
|
||||
setError('Session title is required.');
|
||||
return;
|
||||
}
|
||||
if (selectedFiles.length === 0) {
|
||||
setError('Choose a folder or a set of keyframe images first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setImportBusy(true);
|
||||
try {
|
||||
const frames = await prepareGameplayFrames(selectedFiles);
|
||||
if (frames.length === 0) {
|
||||
throw new Error('No image frames were found in that folder.');
|
||||
}
|
||||
|
||||
if (form.presetName.trim()) {
|
||||
await saveGameplayPreset({
|
||||
game_id: form.gameId.trim(),
|
||||
display_name: form.presetName.trim(),
|
||||
coaching_focus: form.coachingFocus
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean),
|
||||
audio_feedback: form.audioFeedback,
|
||||
spoiler_mode: form.spoilerMode,
|
||||
notes: form.notes.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
const session = await registerGameplaySession({
|
||||
game_id: form.gameId.trim(),
|
||||
session_title: form.sessionTitle.trim(),
|
||||
source_label: form.sourceLabel.trim() || null,
|
||||
spoiler_mode: form.spoilerMode,
|
||||
preset_id: form.gameId.trim() || null,
|
||||
frames: frames.map(
|
||||
frame =>
|
||||
({
|
||||
file_name: frame.file_name,
|
||||
image_ref: frame.image_ref,
|
||||
captured_at_ms: frame.captured_at_ms,
|
||||
}) satisfies GameplayFrameInput
|
||||
),
|
||||
});
|
||||
|
||||
setAnalysisBusy(true);
|
||||
const analyzed = await analyzeGameplaySession({
|
||||
session_id: session.session_id,
|
||||
max_highlights: 5,
|
||||
platforms: splitPlatforms(form.platforms),
|
||||
});
|
||||
setActiveSession(analyzed);
|
||||
setQuestionAnswer('');
|
||||
onToast?.({
|
||||
type: 'success',
|
||||
title: 'Gameplay session analyzed',
|
||||
message: `Reviewed ${analyzed.frames.length} frame(s) for ${analyzed.game_id}.`,
|
||||
});
|
||||
void refreshSessions();
|
||||
} catch (err) {
|
||||
const message = normalizeGameplayError(err);
|
||||
setError(message);
|
||||
onToast?.({ type: 'error', title: 'Gameplay review failed', message });
|
||||
} finally {
|
||||
setAnalysisBusy(false);
|
||||
setImportBusy(false);
|
||||
}
|
||||
}, [form, onToast, refreshSessions, selectedFiles]);
|
||||
|
||||
const handleQuestion = useCallback(async () => {
|
||||
if (!activeSession) {
|
||||
setError('Select or analyze a session first.');
|
||||
return;
|
||||
}
|
||||
setQuestionBusy(true);
|
||||
try {
|
||||
const response = await askGameplaySession({ session_id: activeSession.session_id, question });
|
||||
setQuestionAnswer(response.answer);
|
||||
} catch (err) {
|
||||
setError(normalizeGameplayError(err));
|
||||
} finally {
|
||||
setQuestionBusy(false);
|
||||
}
|
||||
}, [activeSession, question]);
|
||||
|
||||
const handleDraftClips = useCallback(async () => {
|
||||
if (!activeSession) return;
|
||||
setDraftBusy(true);
|
||||
try {
|
||||
const drafts = await draftGameplayClipMetadata({
|
||||
session_id: activeSession.session_id,
|
||||
platform: splitPlatforms(form.platforms)[0] || 'twitch',
|
||||
highlight_id: activeHighlights[0]?.id ?? null,
|
||||
});
|
||||
setActiveSession(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
analysis: prev.analysis
|
||||
? { ...prev.analysis, draft_metadata: drafts }
|
||||
: prev.analysis,
|
||||
}
|
||||
: prev
|
||||
);
|
||||
} catch (err) {
|
||||
setError(normalizeGameplayError(err));
|
||||
} finally {
|
||||
setDraftBusy(false);
|
||||
}
|
||||
}, [activeHighlights, activeSession, form.platforms]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="gameplay-review-workspace">
|
||||
<div className="rounded-2xl border border-sage-200 bg-gradient-to-br from-white via-sage-50 to-amber-50 p-5 shadow-soft">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-sage-700">
|
||||
Gameplay review
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold text-stone-900">
|
||||
Import a session, find the clips, draft the post
|
||||
</h2>
|
||||
<p className="max-w-2xl text-sm text-stone-600">
|
||||
Load a folder of keyframes from a long session, generate a concise recap, ask
|
||||
follow-up questions, and turn the strongest moments into platform-ready clip metadata.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-sage-200 bg-white/80 px-4 py-3 text-sm text-stone-700 shadow-sm">
|
||||
<div className="font-semibold text-stone-900">Selected files</div>
|
||||
<div>{selectedFiles.length} file(s)</div>
|
||||
<div>{t(formatSpoilerMode(form.spoilerMode))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium text-stone-700">Game</span>
|
||||
<input
|
||||
value={form.gameId}
|
||||
onChange={event => setForm(prev => ({ ...prev, gameId: event.target.value }))}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
|
||||
placeholder="Apex Legends"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium text-stone-700">Session title</span>
|
||||
<input
|
||||
value={form.sessionTitle}
|
||||
onChange={event => setForm(prev => ({ ...prev, sessionTitle: event.target.value }))}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
|
||||
placeholder="Ranked climb on Friday night"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium text-stone-700">Source label</span>
|
||||
<input
|
||||
value={form.sourceLabel}
|
||||
onChange={event => setForm(prev => ({ ...prev, sourceLabel: event.target.value }))}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
|
||||
placeholder="/recordings/apex/night-01"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium text-stone-700">Spoiler mode</span>
|
||||
<select
|
||||
value={form.spoilerMode}
|
||||
onChange={event =>
|
||||
setForm(prev => ({ ...prev, spoilerMode: event.target.value as SpoilerMode }))
|
||||
}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500">
|
||||
<option value="off">Off</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="full">Full</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium text-stone-700">Preset name</span>
|
||||
<input
|
||||
value={form.presetName}
|
||||
onChange={event => setForm(prev => ({ ...prev, presetName: event.target.value }))}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
|
||||
placeholder="Apex coaching preset"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium text-stone-700">Focus areas</span>
|
||||
<input
|
||||
value={form.coachingFocus}
|
||||
onChange={event => setForm(prev => ({ ...prev, coachingFocus: event.target.value }))}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
|
||||
placeholder="aim, positioning, fight selection"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm md:col-span-2 xl:col-span-3">
|
||||
<span className="font-medium text-stone-700">Preset notes</span>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={event => setForm(prev => ({ ...prev, notes: event.target.value }))}
|
||||
className="min-h-24 w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
|
||||
placeholder="What should the reviewer pay attention to?"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm md:col-span-2">
|
||||
<span className="font-medium text-stone-700">Clip platforms</span>
|
||||
<input
|
||||
value={form.platforms}
|
||||
onChange={event => setForm(prev => ({ ...prev, platforms: event.target.value }))}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
|
||||
placeholder="twitch,kick,youtube"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 self-end text-sm text-stone-700 md:col-span-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.audioFeedback}
|
||||
onChange={event =>
|
||||
setForm(prev => ({ ...prev, audioFeedback: event.target.checked }))
|
||||
}
|
||||
/>
|
||||
Enable audio summary notes
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
{...DIRECTORY_INPUT_PROPS}
|
||||
className="hidden"
|
||||
onChange={handleFilesChanged}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectFiles}
|
||||
className="rounded-xl border border-sage-200 bg-white px-4 py-2 text-sm font-semibold text-sage-800 shadow-sm transition hover:bg-sage-50">
|
||||
Choose folder / frames
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportSession}
|
||||
disabled={importBusy || analysisBusy}
|
||||
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{analysisBusy ? 'Analyzing…' : importBusy ? 'Importing…' : 'Import and analyze'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDraftClips}
|
||||
disabled={draftBusy || !activeSession}
|
||||
className="rounded-xl border border-amber-200 bg-white px-4 py-2 text-sm font-semibold text-amber-800 shadow-sm transition hover:bg-amber-50 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{draftBusy ? 'Drafting…' : 'Refresh clip metadata'}
|
||||
</button>
|
||||
<div className="text-xs text-stone-500">
|
||||
The core will analyze image frames. For raw video files, point this at extracted
|
||||
keyframes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<section className="space-y-4 rounded-2xl border border-stone-200 bg-white p-5 shadow-soft">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-stone-900">Session viewer</h3>
|
||||
<p className="text-sm text-stone-500">
|
||||
Recap, highlights, and clip candidates for the current session.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-xs text-stone-600">
|
||||
{activeSession ? activeSession.game_id : 'No session selected'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeSession ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-sage-200 bg-sage-50 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-sage-900">
|
||||
{activeSession.session_title}
|
||||
</div>
|
||||
<div className="text-xs text-sage-700">
|
||||
Imported {formatTimestamp(activeSession.imported_at_ms)} ·{' '}
|
||||
{activeSession.frames.length} frame(s)
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-sage-700">
|
||||
{t(formatSpoilerMode(activeSession.spoiler_mode))}
|
||||
</div>
|
||||
</div>
|
||||
{activeSession.analysis?.spoiler_note && (
|
||||
<p className="mt-3 text-sm text-stone-700">
|
||||
{activeSession.analysis.spoiler_note}
|
||||
</p>
|
||||
)}
|
||||
{activeSession.analysis && (
|
||||
<p className="mt-3 whitespace-pre-line text-sm text-stone-800">
|
||||
{activeSession.analysis.recap}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-stone-200 p-4">
|
||||
<div className="text-sm font-semibold text-stone-900">Highlights</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{activeHighlights.length > 0 ? (
|
||||
activeHighlights.map(highlight => (
|
||||
<article
|
||||
key={highlight.id}
|
||||
className="rounded-lg border border-stone-200 bg-stone-50 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-medium text-stone-900">{highlight.title}</div>
|
||||
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] text-stone-500">
|
||||
{(highlight.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs uppercase tracking-wide text-stone-500">
|
||||
{highlight.kind} · frame {highlight.frame_index + 1} ·{' '}
|
||||
{formatTimestamp(highlight.captured_at_ms)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-stone-700">{highlight.rationale}</p>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-stone-500">No highlights detected yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200 p-4">
|
||||
<div className="text-sm font-semibold text-stone-900">Clip candidates</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{activeClips.length > 0 ? (
|
||||
activeClips.map(clip => (
|
||||
<article
|
||||
key={clip.id}
|
||||
className="rounded-lg border border-amber-100 bg-amber-50 p-3">
|
||||
<div className="font-medium text-amber-950">{clip.start_label}</div>
|
||||
<div className="text-xs text-amber-800">through {clip.end_label}</div>
|
||||
<p className="mt-2 text-sm text-stone-700">{clip.rationale}</p>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-stone-500">No clip candidates yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200 p-4">
|
||||
<div className="text-sm font-semibold text-stone-900">Platform drafts</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
{activeDrafts.length > 0 ? (
|
||||
activeDrafts.map(draft => (
|
||||
<article
|
||||
key={`${draft.platform}-${draft.title}`}
|
||||
className="rounded-lg border border-stone-200 bg-white p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
{draft.platform}
|
||||
</div>
|
||||
<div className="mt-1 font-medium text-stone-900">{draft.title}</div>
|
||||
<p className="mt-2 text-sm text-stone-700 whitespace-pre-line">
|
||||
{draft.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{draft.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-stone-100 px-2 py-0.5 text-[11px] text-stone-600">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-stone-500">
|
||||
Draft metadata will appear after analysis.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-sage-200 bg-sage-50 p-4">
|
||||
<div className="text-sm font-semibold text-sage-900">Ask this session</div>
|
||||
<div className="mt-3 flex flex-col gap-3 lg:flex-row">
|
||||
<input
|
||||
value={question}
|
||||
onChange={event => setQuestion(event.target.value)}
|
||||
className="flex-1 rounded-xl border border-sage-200 bg-white px-3 py-2 outline-none ring-0 focus:border-primary-500"
|
||||
placeholder="What were my best fights?"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuestion}
|
||||
disabled={questionBusy}
|
||||
className="rounded-xl bg-sage-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sage-800 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{questionBusy ? 'Thinking…' : 'Ask session'}
|
||||
</button>
|
||||
</div>
|
||||
{questionAnswer && (
|
||||
<p className="mt-3 whitespace-pre-line text-sm text-stone-800">
|
||||
{questionAnswer}
|
||||
</p>
|
||||
)}
|
||||
{activeSession.analysis?.follow_up_questions &&
|
||||
activeSession.analysis.follow_up_questions.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-stone-600">
|
||||
{activeSession.analysis.follow_up_questions.map(item => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => setQuestion(item)}
|
||||
className="rounded-full border border-sage-200 bg-white px-3 py-1 transition hover:bg-sage-50">
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-stone-300 bg-stone-50 p-8 text-center text-sm text-stone-500">
|
||||
Import a folder of keyframes to create the first gameplay session.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4 rounded-2xl border border-stone-200 bg-white p-5 shadow-soft">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-stone-900">Session history</h3>
|
||||
<p className="text-sm text-stone-500">
|
||||
Previously imported or analyzed sessions for this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentSessions.length > 0 ? (
|
||||
recentSessions.map(session => (
|
||||
<button
|
||||
key={session.session_id}
|
||||
type="button"
|
||||
onClick={() => setActiveSession(session)}
|
||||
className={`w-full rounded-xl border p-3 text-left transition ${
|
||||
activeSession?.session_id === session.session_id
|
||||
? 'border-primary-300 bg-primary-50'
|
||||
: 'border-stone-200 bg-white hover:bg-stone-50'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-stone-900">{session.session_title}</div>
|
||||
<div className="text-xs text-stone-500">{session.game_id}</div>
|
||||
</div>
|
||||
<div className="text-right text-[11px] text-stone-500">
|
||||
<div>{t(formatSpoilerMode(session.spoiler_mode))}</div>
|
||||
<div>{session.frames.length} frame(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-stone-500">
|
||||
Imported {formatTimestamp(session.imported_at_ms)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-stone-500">No saved sessions yet.</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshSessions()}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 transition hover:bg-stone-50">
|
||||
Refresh history
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -175,10 +175,6 @@ const ar1: TranslationMap = {
|
||||
'memory.noResults': 'لم يتم العثور على ذكريات',
|
||||
'memory.empty': 'لا توجد ذكريات بعد. تُنشأ الذكريات تلقائيًا أثناء تفاعلك.',
|
||||
'memory.tab.memory': 'الذاكرة',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'اللاوعي',
|
||||
'memory.tab.dreams': 'الأحلام',
|
||||
'memory.tab.calls': 'المكالمات',
|
||||
|
||||
@@ -177,10 +177,6 @@ const bn1: TranslationMap = {
|
||||
'memory.empty':
|
||||
'এখনো কোনো মেমোরি নেই। আপনি যত ইন্টারঅ্যাক্ট করবেন, মেমোরি স্বয়ংক্রিয়ভাবে তৈরি হবে।',
|
||||
'memory.tab.memory': 'মেমোরি',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'সাবকনশাস',
|
||||
'memory.tab.dreams': 'স্বপ্ন',
|
||||
'memory.tab.calls': 'কল',
|
||||
|
||||
@@ -216,10 +216,6 @@ const de1: TranslationMap = {
|
||||
'memory.empty':
|
||||
'Noch keine Erinnerungen. Erinnerungen werden automatisch erstellt, während du interagierst.',
|
||||
'memory.tab.memory': 'Erinnerung',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'Unterbewusstsein',
|
||||
'memory.tab.dreams': 'Träume',
|
||||
'memory.tab.calls': 'Anrufe',
|
||||
|
||||
@@ -484,10 +484,6 @@ const en1: TranslationMap = {
|
||||
'memory.noResults': 'No memories found',
|
||||
'memory.empty': 'No memories yet. Memories are created automatically as you interact.',
|
||||
'memory.tab.memory': 'Memory',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'Subconscious',
|
||||
'memory.tab.dreams': 'Dreams',
|
||||
'memory.tab.calls': 'Calls',
|
||||
|
||||
@@ -183,10 +183,6 @@ const es1: TranslationMap = {
|
||||
'memory.noResults': 'No se encontraron recuerdos',
|
||||
'memory.empty': 'Sin recuerdos aún. Los recuerdos se crean automáticamente mientras interactúas.',
|
||||
'memory.tab.memory': 'Memoria',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'Subconsciente',
|
||||
'memory.tab.dreams': 'Sueños',
|
||||
'memory.tab.calls': 'Llamadas',
|
||||
|
||||
@@ -183,10 +183,6 @@ const fr1: TranslationMap = {
|
||||
'memory.empty':
|
||||
"Aucun souvenir pour l'instant. Les souvenirs sont créés automatiquement au fil de tes interactions.",
|
||||
'memory.tab.memory': 'Mémoire',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'Subconscient',
|
||||
'memory.tab.dreams': 'Rêves',
|
||||
'memory.tab.calls': 'Appels',
|
||||
|
||||
@@ -175,10 +175,6 @@ const hi1: TranslationMap = {
|
||||
'memory.noResults': 'कोई मेमोरी नहीं मिली',
|
||||
'memory.empty': 'अभी कोई मेमोरी नहीं है। बातचीत के दौरान मेमोरी अपने आप बनती है।',
|
||||
'memory.tab.memory': 'मेमोरी',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'सबकॉन्शस',
|
||||
'memory.tab.dreams': 'ड्रीम्स',
|
||||
'memory.tab.calls': 'कॉल्स',
|
||||
|
||||
@@ -176,10 +176,6 @@ const id1: TranslationMap = {
|
||||
'memory.noResults': 'Memori tidak ditemukan',
|
||||
'memory.empty': 'Belum ada memori. Memori dibuat otomatis saat Anda berinteraksi.',
|
||||
'memory.tab.memory': 'Memori',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'Bawah sadar',
|
||||
'memory.tab.dreams': 'Mimpi',
|
||||
'memory.tab.calls': 'Panggilan',
|
||||
|
||||
@@ -181,10 +181,6 @@ const it1: TranslationMap = {
|
||||
'memory.empty':
|
||||
'Nessuna memoria ancora. Le memorie vengono create automaticamente mentre interagisci.',
|
||||
'memory.tab.memory': 'Memoria',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'Subconscio',
|
||||
'memory.tab.dreams': 'Sogni',
|
||||
'memory.tab.calls': 'Chiamate',
|
||||
|
||||
@@ -175,10 +175,6 @@ const ko1: TranslationMap = {
|
||||
'memory.noResults': '메모리를 찾을 수 없습니다',
|
||||
'memory.empty': '아직 메모리가 없습니다. 메모리는 상호작용하면서 자동으로 생성됩니다.',
|
||||
'memory.tab.memory': '메모리',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': '잠재의식',
|
||||
'memory.tab.dreams': '꿈',
|
||||
'memory.tab.calls': '통화',
|
||||
|
||||
@@ -200,10 +200,6 @@ const pl1: TranslationMap = {
|
||||
'memory.empty':
|
||||
'Brak wspomnień. Wspomnienia powstają automatycznie podczas korzystania z aplikacji.',
|
||||
'memory.tab.memory': 'Pamięć',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'Podświadomość',
|
||||
'memory.tab.dreams': 'Marzenia senne',
|
||||
'memory.tab.calls': 'Połączenia',
|
||||
|
||||
@@ -182,10 +182,6 @@ const pt1: TranslationMap = {
|
||||
'memory.empty':
|
||||
'Nenhuma memória ainda. As memórias são criadas automaticamente conforme você interage.',
|
||||
'memory.tab.memory': 'Memória',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'Subconsciente',
|
||||
'memory.tab.dreams': 'Sonhos',
|
||||
'memory.tab.calls': 'Chamadas',
|
||||
|
||||
@@ -176,10 +176,6 @@ const ru1: TranslationMap = {
|
||||
'memory.noResults': 'Воспоминания не найдены',
|
||||
'memory.empty': 'Воспоминаний пока нет. Они создаются автоматически в процессе общения.',
|
||||
'memory.tab.memory': 'Память',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'Подсознание',
|
||||
'memory.tab.dreams': 'Сны',
|
||||
'memory.tab.calls': 'Звонки',
|
||||
|
||||
@@ -171,10 +171,6 @@ const zhCN1: TranslationMap = {
|
||||
'memory.noResults': '未找到记忆',
|
||||
'memory.empty': '暂无记忆。记忆将在你交互时自动创建。',
|
||||
'memory.tab.memory': '记忆',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': '潜意识',
|
||||
'memory.tab.dreams': '梦境',
|
||||
'memory.tab.calls': '调用记录',
|
||||
|
||||
@@ -292,10 +292,6 @@ const en: TranslationMap = {
|
||||
'memory.noResults': 'No memories found',
|
||||
'memory.empty': 'No memories yet. Memories are created automatically as you interact.',
|
||||
'memory.tab.memory': 'Memory',
|
||||
'memory.tab.gameplay': 'Gameplay',
|
||||
'gameplay.spoiler.off': 'Spoiler-safe',
|
||||
'gameplay.spoiler.light': 'Light spoilers',
|
||||
'gameplay.spoiler.full': 'Full spoilers',
|
||||
'memory.tab.subconscious': 'Subconscious',
|
||||
'memory.tab.dreams': 'Dreams',
|
||||
'memory.tab.calls': 'Calls',
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||
import DiagramViewerTab from '../components/intelligence/DiagramViewerTab';
|
||||
import { GameplayReviewWorkspace } from '../components/intelligence/GameplayReviewWorkspace';
|
||||
import GraphCentralityTab from '../components/intelligence/GraphCentralityTab';
|
||||
import IntelligenceCallsTab from '../components/intelligence/IntelligenceCallsTab';
|
||||
import IntelligenceDreamsTab from '../components/intelligence/IntelligenceDreamsTab';
|
||||
@@ -24,7 +23,6 @@ import type {
|
||||
|
||||
type IntelligenceTab =
|
||||
| 'memory'
|
||||
| 'gameplay'
|
||||
| 'subconscious'
|
||||
| 'calls'
|
||||
| 'dreams'
|
||||
@@ -100,7 +98,6 @@ export default function Intelligence() {
|
||||
|
||||
const tabs: { id: IntelligenceTab; label: string; comingSoon?: boolean }[] = [
|
||||
{ id: 'memory', label: t('memory.tab.memory') },
|
||||
{ id: 'gameplay', label: t('memory.tab.gameplay') },
|
||||
{ id: 'subconscious', label: t('memory.tab.subconscious') },
|
||||
{ id: 'tasks', label: 'Tasks' },
|
||||
{ id: 'diagram', label: t('memory.tab.diagram') },
|
||||
@@ -161,8 +158,6 @@ export default function Intelligence() {
|
||||
{/* Tab content */}
|
||||
{activeTab === 'memory' && <MemoryWorkspace onToast={addToast} />}
|
||||
|
||||
{activeTab === 'gameplay' && <GameplayReviewWorkspace onToast={addToast} />}
|
||||
|
||||
{activeTab === 'subconscious' && (
|
||||
<IntelligenceSubconsciousTab
|
||||
addSubconsciousTask={addSubconsciousTask}
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
analyzeGameplaySession,
|
||||
askGameplaySession,
|
||||
draftGameplayClipMetadata,
|
||||
flattenClipCandidates,
|
||||
flattenDrafts,
|
||||
flattenHighlights,
|
||||
formatSpoilerMode,
|
||||
type GameplayReviewAnalysis,
|
||||
type GameplayReviewSession,
|
||||
listGameplayPresets,
|
||||
listGameplaySessions,
|
||||
normalizeGameplayError,
|
||||
prepareGameplayFrames,
|
||||
registerGameplaySession,
|
||||
saveGameplayPreset,
|
||||
} from './gameplayReviewService';
|
||||
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
|
||||
vi.mock('./coreRpcClient', () => ({
|
||||
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
|
||||
}));
|
||||
|
||||
function makeImageFile(
|
||||
name: string,
|
||||
bytes: number[],
|
||||
options: { type?: string; relativePath?: string; lastModified?: number } = {}
|
||||
): File {
|
||||
const file = new File([new Uint8Array(bytes)], name, {
|
||||
type: options.type ?? 'image/png',
|
||||
lastModified: options.lastModified ?? 0,
|
||||
});
|
||||
if (options.relativePath !== undefined) {
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: options.relativePath });
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
const ANALYSIS: GameplayReviewAnalysis = {
|
||||
recap: 'Recap',
|
||||
highlights: [
|
||||
{
|
||||
id: 'h1',
|
||||
frame_index: 0,
|
||||
captured_at_ms: 1,
|
||||
title: 'Clutch',
|
||||
rationale: 'Clean',
|
||||
confidence: 0.9,
|
||||
kind: 'highlight',
|
||||
},
|
||||
],
|
||||
clip_candidates: [
|
||||
{
|
||||
id: 'c1',
|
||||
frame_index: 0,
|
||||
start_label: 'a',
|
||||
end_label: 'b',
|
||||
rationale: 'why',
|
||||
confidence: 0.8,
|
||||
},
|
||||
],
|
||||
draft_metadata: [{ platform: 'twitch', title: 't', description: 'd', tags: ['x'] }],
|
||||
follow_up_questions: ['q?'],
|
||||
spoiler_note: null,
|
||||
};
|
||||
|
||||
const SESSION: GameplayReviewSession = {
|
||||
session_id: 's1',
|
||||
game_id: 'Apex',
|
||||
session_title: 'Ranked',
|
||||
source_label: null,
|
||||
spoiler_mode: 'light',
|
||||
preset_id: null,
|
||||
imported_at_ms: 1000,
|
||||
analyzed_at_ms: 2000,
|
||||
frames: [],
|
||||
analysis: ANALYSIS,
|
||||
};
|
||||
|
||||
describe('gameplayReviewService', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
});
|
||||
|
||||
describe('prepareGameplayFrames', () => {
|
||||
it('filters to images, sorts by lowercased path, caps to maxFrames, and base64-encodes', async () => {
|
||||
const files = [
|
||||
makeImageFile('B.png', [1, 2, 3], { type: 'image/png', lastModified: 42 }),
|
||||
makeImageFile('a.png', [4, 5], { type: 'image/png', lastModified: 7 }),
|
||||
// Non-image file should be filtered out.
|
||||
new File([new Uint8Array([9])], 'notes.txt', { type: 'text/plain' }),
|
||||
];
|
||||
|
||||
const prepared = await prepareGameplayFrames(files, 5);
|
||||
|
||||
expect(prepared).toHaveLength(2);
|
||||
// 'a.png' sorts before 'b.png' (case-insensitive).
|
||||
expect(prepared[0].file_name).toBe('a.png');
|
||||
expect(prepared[1].file_name).toBe('B.png');
|
||||
expect(prepared[0].source_name).toBe('a.png');
|
||||
expect(prepared[0].image_ref.startsWith('data:image/png;base64,')).toBe(true);
|
||||
expect(prepared[0].captured_at_ms).toBe(7);
|
||||
// Decoding the payload yields the original bytes.
|
||||
const payload = prepared[0].image_ref.split(',')[1];
|
||||
const decoded = atob(payload);
|
||||
expect([decoded.charCodeAt(0), decoded.charCodeAt(1)]).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
it('prefers webkitRelativePath and maps lastModified=0 to null', async () => {
|
||||
const file = makeImageFile('frame.png', [10], {
|
||||
type: 'image/jpeg',
|
||||
relativePath: 'session/frame.png',
|
||||
lastModified: 0,
|
||||
});
|
||||
|
||||
const [prepared] = await prepareGameplayFrames([file]);
|
||||
|
||||
expect(prepared.file_name).toBe('session/frame.png');
|
||||
expect(prepared.source_name).toBe('session/frame.png');
|
||||
expect(prepared.image_ref.startsWith('data:image/jpeg;base64,')).toBe(true);
|
||||
expect(prepared.captured_at_ms).toBeNull();
|
||||
});
|
||||
|
||||
it('honors maxFrames by slicing the sorted image list', async () => {
|
||||
const files = [
|
||||
makeImageFile('1.png', [1]),
|
||||
makeImageFile('2.png', [2]),
|
||||
makeImageFile('3.png', [3]),
|
||||
];
|
||||
|
||||
const prepared = await prepareGameplayFrames(files, 2);
|
||||
|
||||
expect(prepared.map(frame => frame.file_name)).toEqual(['1.png', '2.png']);
|
||||
});
|
||||
|
||||
it('floors maxFrames at 1 even when callers pass 0', async () => {
|
||||
const prepared = await prepareGameplayFrames([makeImageFile('only.png', [1])], 0);
|
||||
|
||||
expect(prepared).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RPC wrappers', () => {
|
||||
it('registerGameplaySession forwards the payload', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce(SESSION);
|
||||
const payload = { game_id: 'Apex', session_title: 'Ranked', frames: [] };
|
||||
|
||||
const result = await registerGameplaySession(payload);
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.gameplay_review_register_session',
|
||||
params: payload,
|
||||
});
|
||||
expect(result).toBe(SESSION);
|
||||
});
|
||||
|
||||
it('analyzeGameplaySession forwards the payload', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce(SESSION);
|
||||
const payload = { session_id: 's1', max_highlights: 5, platforms: ['twitch'] };
|
||||
|
||||
const result = await analyzeGameplaySession(payload);
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.gameplay_review_analyze_session',
|
||||
params: payload,
|
||||
});
|
||||
expect(result).toBe(SESSION);
|
||||
});
|
||||
|
||||
it('listGameplaySessions sends game_id filter when provided', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce([SESSION]);
|
||||
|
||||
const result = await listGameplaySessions('Apex');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.gameplay_review_list_sessions',
|
||||
params: { game_id: 'Apex' },
|
||||
});
|
||||
expect(result).toEqual([SESSION]);
|
||||
});
|
||||
|
||||
it('listGameplaySessions sends empty params when no game id', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce([]);
|
||||
|
||||
await listGameplaySessions();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.gameplay_review_list_sessions',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('saveGameplayPreset forwards the payload', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({});
|
||||
const payload = {
|
||||
game_id: 'Apex',
|
||||
display_name: 'Preset',
|
||||
coaching_focus: ['aim'],
|
||||
audio_feedback: true,
|
||||
spoiler_mode: 'full' as const,
|
||||
notes: null,
|
||||
};
|
||||
|
||||
await saveGameplayPreset(payload);
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.gameplay_review_set_preset',
|
||||
params: payload,
|
||||
});
|
||||
});
|
||||
|
||||
it('listGameplayPresets calls the presets RPC', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await listGameplayPresets();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.gameplay_review_list_presets',
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('askGameplaySession forwards the question payload', async () => {
|
||||
const answer = { answer: 'A', matched_highlights: [], suggested_follow_up: [] };
|
||||
mockCallCoreRpc.mockResolvedValueOnce(answer);
|
||||
const payload = { session_id: 's1', question: 'best?' };
|
||||
|
||||
const result = await askGameplaySession(payload);
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.gameplay_review_ask_session',
|
||||
params: payload,
|
||||
});
|
||||
expect(result).toBe(answer);
|
||||
});
|
||||
|
||||
it('draftGameplayClipMetadata forwards the payload', async () => {
|
||||
const drafts = [{ platform: 'twitch', title: 't', description: 'd', tags: [] }];
|
||||
mockCallCoreRpc.mockResolvedValueOnce(drafts);
|
||||
const payload = { session_id: 's1', platform: 'twitch', highlight_id: 'h1' };
|
||||
|
||||
const result = await draftGameplayClipMetadata(payload);
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.gameplay_review_draft_clip_metadata',
|
||||
params: payload,
|
||||
});
|
||||
expect(result).toBe(drafts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flatten helpers', () => {
|
||||
it('flattenHighlights returns analysis highlights or [] when missing', () => {
|
||||
expect(flattenHighlights(SESSION)).toBe(ANALYSIS.highlights);
|
||||
expect(flattenHighlights(null)).toEqual([]);
|
||||
expect(flattenHighlights({ ...SESSION, analysis: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it('flattenDrafts returns analysis drafts or [] when missing', () => {
|
||||
expect(flattenDrafts(SESSION)).toBe(ANALYSIS.draft_metadata);
|
||||
expect(flattenDrafts(null)).toEqual([]);
|
||||
expect(flattenDrafts({ ...SESSION, analysis: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it('flattenClipCandidates returns analysis clips or [] when missing', () => {
|
||||
expect(flattenClipCandidates(SESSION)).toBe(ANALYSIS.clip_candidates);
|
||||
expect(flattenClipCandidates(null)).toEqual([]);
|
||||
expect(flattenClipCandidates({ ...SESSION, analysis: null })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSpoilerMode', () => {
|
||||
it('maps each spoiler mode to its label', () => {
|
||||
expect(formatSpoilerMode('off')).toBe('gameplay.spoiler.off');
|
||||
expect(formatSpoilerMode('full')).toBe('gameplay.spoiler.full');
|
||||
expect(formatSpoilerMode('light')).toBe('gameplay.spoiler.light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGameplayError', () => {
|
||||
it('returns the message for Error instances', () => {
|
||||
expect(normalizeGameplayError(new Error('boom'))).toBe('boom');
|
||||
});
|
||||
|
||||
it('returns the string for string errors', () => {
|
||||
expect(normalizeGameplayError('plain failure')).toBe('plain failure');
|
||||
});
|
||||
|
||||
it('returns a default message for unknown error shapes', () => {
|
||||
expect(normalizeGameplayError({ weird: true })).toBe('Gameplay review failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import { callCoreRpc } from './coreRpcClient';
|
||||
|
||||
const log = debug('gameplay-review');
|
||||
|
||||
export type SpoilerMode = 'off' | 'light' | 'full';
|
||||
|
||||
export interface GameplayFrameInput {
|
||||
file_name: string;
|
||||
image_ref: string;
|
||||
captured_at_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface GameplayReviewSessionInput {
|
||||
game_id: string;
|
||||
session_title: string;
|
||||
source_label?: string | null;
|
||||
spoiler_mode?: SpoilerMode | null;
|
||||
preset_id?: string | null;
|
||||
frames: GameplayFrameInput[];
|
||||
}
|
||||
|
||||
export interface GameplayReviewAnalysisInput {
|
||||
session_id: string;
|
||||
max_highlights?: number;
|
||||
platforms?: string[];
|
||||
}
|
||||
|
||||
export interface GameplayPresetInput {
|
||||
game_id: string;
|
||||
display_name: string;
|
||||
coaching_focus: string[];
|
||||
audio_feedback: boolean;
|
||||
spoiler_mode: SpoilerMode;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface GameplayReviewPreset extends GameplayPresetInput {
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface GameplayReviewQuestionInput {
|
||||
session_id: string;
|
||||
question: string;
|
||||
}
|
||||
|
||||
export interface GameplayReviewClipInput {
|
||||
session_id: string;
|
||||
platform?: string | null;
|
||||
highlight_id?: string | null;
|
||||
}
|
||||
|
||||
export interface GameplayHighlight {
|
||||
id: string;
|
||||
frame_index: number;
|
||||
captured_at_ms?: number | null;
|
||||
title: string;
|
||||
rationale: string;
|
||||
confidence: number;
|
||||
kind: 'highlight' | 'mistake' | 'coaching';
|
||||
}
|
||||
|
||||
export interface GameplayClipCandidate {
|
||||
id: string;
|
||||
frame_index: number;
|
||||
start_label: string;
|
||||
end_label: string;
|
||||
rationale: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface GameplayPlatformDraft {
|
||||
platform: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface GameplayReviewAnalysis {
|
||||
recap: string;
|
||||
highlights: GameplayHighlight[];
|
||||
clip_candidates: GameplayClipCandidate[];
|
||||
draft_metadata: GameplayPlatformDraft[];
|
||||
follow_up_questions: string[];
|
||||
spoiler_note?: string | null;
|
||||
}
|
||||
|
||||
export interface GameplayReviewSession {
|
||||
session_id: string;
|
||||
game_id: string;
|
||||
session_title: string;
|
||||
source_label?: string | null;
|
||||
spoiler_mode: SpoilerMode;
|
||||
preset_id?: string | null;
|
||||
imported_at_ms: number;
|
||||
analyzed_at_ms?: number | null;
|
||||
frames: GameplayFrameInput[];
|
||||
analysis?: GameplayReviewAnalysis | null;
|
||||
}
|
||||
|
||||
export interface GameplayReviewQuestionResult {
|
||||
answer: string;
|
||||
matched_highlights: GameplayHighlight[];
|
||||
suggested_follow_up: string[];
|
||||
}
|
||||
|
||||
export interface PreparedGameplayFrame extends GameplayFrameInput {
|
||||
source_name: string;
|
||||
}
|
||||
|
||||
export async function prepareGameplayFrames(
|
||||
files: File[],
|
||||
maxFrames = 12
|
||||
): Promise<PreparedGameplayFrame[]> {
|
||||
const imageFiles = files
|
||||
.filter(file => file.type.startsWith('image/'))
|
||||
.sort((left, right) => {
|
||||
const leftName = (left.webkitRelativePath || left.name).toLowerCase();
|
||||
const rightName = (right.webkitRelativePath || right.name).toLowerCase();
|
||||
return leftName.localeCompare(rightName);
|
||||
})
|
||||
.slice(0, Math.max(1, maxFrames));
|
||||
|
||||
log(
|
||||
'prepareGameplayFrames: selected %d image(s) from %d file(s)',
|
||||
imageFiles.length,
|
||||
files.length
|
||||
);
|
||||
const prepared: PreparedGameplayFrame[] = [];
|
||||
for (const file of imageFiles) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let base64 = '';
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
base64 += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
}
|
||||
prepared.push({
|
||||
source_name: file.webkitRelativePath || file.name,
|
||||
file_name: file.webkitRelativePath || file.name,
|
||||
image_ref: `data:${file.type || 'image/png'};base64,${btoa(base64)}`,
|
||||
captured_at_ms: file.lastModified || null,
|
||||
});
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export async function registerGameplaySession(
|
||||
payload: GameplayReviewSessionInput
|
||||
): Promise<GameplayReviewSession> {
|
||||
const result = await callCoreRpc<GameplayReviewSession>({
|
||||
method: 'openhuman.gameplay_review_register_session',
|
||||
params: payload,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function analyzeGameplaySession(
|
||||
payload: GameplayReviewAnalysisInput
|
||||
): Promise<GameplayReviewSession> {
|
||||
const result = await callCoreRpc<GameplayReviewSession>({
|
||||
method: 'openhuman.gameplay_review_analyze_session',
|
||||
params: payload,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listGameplaySessions(gameId?: string): Promise<GameplayReviewSession[]> {
|
||||
const result = await callCoreRpc<GameplayReviewSession[]>({
|
||||
method: 'openhuman.gameplay_review_list_sessions',
|
||||
params: gameId ? { game_id: gameId } : {},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function saveGameplayPreset(payload: GameplayPresetInput): Promise<unknown> {
|
||||
return callCoreRpc({ method: 'openhuman.gameplay_review_set_preset', params: payload });
|
||||
}
|
||||
|
||||
export async function listGameplayPresets(): Promise<GameplayReviewPreset[]> {
|
||||
return callCoreRpc<GameplayReviewPreset[]>({ method: 'openhuman.gameplay_review_list_presets' });
|
||||
}
|
||||
|
||||
export async function askGameplaySession(
|
||||
payload: GameplayReviewQuestionInput
|
||||
): Promise<GameplayReviewQuestionResult> {
|
||||
return callCoreRpc<GameplayReviewQuestionResult>({
|
||||
method: 'openhuman.gameplay_review_ask_session',
|
||||
params: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function draftGameplayClipMetadata(
|
||||
payload: GameplayReviewClipInput
|
||||
): Promise<GameplayPlatformDraft[]> {
|
||||
return callCoreRpc<GameplayPlatformDraft[]>({
|
||||
method: 'openhuman.gameplay_review_draft_clip_metadata',
|
||||
params: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function flattenHighlights(session: GameplayReviewSession | null): GameplayHighlight[] {
|
||||
return session?.analysis?.highlights ?? [];
|
||||
}
|
||||
|
||||
export function flattenDrafts(session: GameplayReviewSession | null): GameplayPlatformDraft[] {
|
||||
return session?.analysis?.draft_metadata ?? [];
|
||||
}
|
||||
|
||||
export function flattenClipCandidates(
|
||||
session: GameplayReviewSession | null
|
||||
): GameplayClipCandidate[] {
|
||||
return session?.analysis?.clip_candidates ?? [];
|
||||
}
|
||||
|
||||
export function formatSpoilerMode(mode: SpoilerMode): string {
|
||||
switch (mode) {
|
||||
case 'off':
|
||||
return 'gameplay.spoiler.off';
|
||||
case 'full':
|
||||
return 'gameplay.spoiler.full';
|
||||
default:
|
||||
return 'gameplay.spoiler.light';
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGameplayError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === 'string') return error;
|
||||
return 'Gameplay review failed';
|
||||
}
|
||||
@@ -175,9 +175,6 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
controllers.extend(
|
||||
crate::openhuman::screen_intelligence::all_screen_intelligence_registered_controllers(),
|
||||
);
|
||||
// Desktop gameplay review workflow
|
||||
controllers
|
||||
.extend(crate::openhuman::gameplay_review::all_gameplay_review_registered_controllers());
|
||||
// Backend Socket.IO bridge + related runtime plumbing
|
||||
controllers.extend(crate::openhuman::socket::all_socket_registered_controllers());
|
||||
// Managed Node.js runtime bridge (tool listing + dispatch)
|
||||
@@ -315,7 +312,6 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas
|
||||
.extend(crate::openhuman::channels::providers::web::all_web_channel_controller_schemas());
|
||||
schemas.extend(crate::openhuman::channels::controllers::all_channels_controller_schemas());
|
||||
schemas.extend(crate::openhuman::gameplay_review::all_gameplay_review_controller_schemas());
|
||||
schemas.extend(crate::openhuman::config::all_config_controller_schemas());
|
||||
schemas.extend(crate::openhuman::connectivity::all_connectivity_controller_schemas());
|
||||
schemas.extend(crate::openhuman::credentials::all_credentials_controller_schemas());
|
||||
|
||||
@@ -877,56 +877,6 @@ const CAPABILITIES: &[Capability] = &[
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: LOCAL_RAW,
|
||||
},
|
||||
Capability {
|
||||
id: "screen_intelligence.gameplay_review_import",
|
||||
name: "Import Gameplay Review Sessions",
|
||||
domain: "screen_intelligence",
|
||||
category: CapabilityCategory::ScreenIntelligence,
|
||||
description: "Import local gameplay keyframes or a recordings folder into a review session.",
|
||||
how_to: "Intelligence > Gameplay > Import session",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: LOCAL_RAW,
|
||||
},
|
||||
Capability {
|
||||
id: "screen_intelligence.gameplay_review_analyze",
|
||||
name: "Analyze Gameplay Sessions",
|
||||
domain: "screen_intelligence",
|
||||
category: CapabilityCategory::ScreenIntelligence,
|
||||
description: "Generate a recap, highlight list, and review notes from imported gameplay frames.",
|
||||
how_to: "Intelligence > Gameplay > Analyze session",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: LOCAL_RAW,
|
||||
},
|
||||
Capability {
|
||||
id: "screen_intelligence.gameplay_review_clip_metadata",
|
||||
name: "Draft Clip Metadata",
|
||||
domain: "screen_intelligence",
|
||||
category: CapabilityCategory::ScreenIntelligence,
|
||||
description: "Generate platform-ready titles, descriptions, and tags for gameplay clips.",
|
||||
how_to: "Intelligence > Gameplay > Draft clips",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: LOCAL_RAW,
|
||||
},
|
||||
Capability {
|
||||
id: "screen_intelligence.gameplay_review_presets",
|
||||
name: "Save Game Coaching Presets",
|
||||
domain: "screen_intelligence",
|
||||
category: CapabilityCategory::ScreenIntelligence,
|
||||
description: "Store game-specific coaching focus, spoiler mode, and notes for repeat reviews.",
|
||||
how_to: "Intelligence > Gameplay > Presets",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: LOCAL_RAW,
|
||||
},
|
||||
Capability {
|
||||
id: "screen_intelligence.gameplay_review_spoilers",
|
||||
name: "Set Spoiler Controls",
|
||||
domain: "screen_intelligence",
|
||||
category: CapabilityCategory::ScreenIntelligence,
|
||||
description: "Keep gameplay coaching spoiler-safe or story-aware per session.",
|
||||
how_to: "Intelligence > Gameplay > Spoiler mode",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: LOCAL_RAW,
|
||||
},
|
||||
Capability {
|
||||
id: "screen_intelligence.configure_capture_fps",
|
||||
name: "Configure Capture FPS",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//! Gameplay session review, highlight detection, and clip drafting.
|
||||
|
||||
pub mod ops;
|
||||
mod schemas;
|
||||
mod store;
|
||||
pub mod types;
|
||||
|
||||
pub use ops as rpc;
|
||||
pub use ops::*;
|
||||
pub use schemas::{
|
||||
all_controller_schemas as all_gameplay_review_controller_schemas,
|
||||
all_registered_controllers as all_gameplay_review_registered_controllers,
|
||||
};
|
||||
pub use types::*;
|
||||
@@ -1,875 +0,0 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::Utc;
|
||||
use log::{debug, trace, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::screen_intelligence::{global_engine, CaptureFrame};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::store;
|
||||
use super::types::{
|
||||
GameplayClipCandidate, GameplayFrameInput, GameplayHighlight, GameplayPlatformDraft,
|
||||
GameplayPresetInput, GameplayReviewAnalysis, GameplayReviewAnalysisInput,
|
||||
GameplayReviewClipInput, GameplayReviewPreset, GameplayReviewQuestionInput,
|
||||
GameplayReviewQuestionResult, GameplayReviewSession, GameplayReviewSessionInput, HighlightKind,
|
||||
SpoilerMode,
|
||||
};
|
||||
|
||||
const DEFAULT_PLATFORMS: &[&str] = &["twitch", "kick", "youtube"];
|
||||
|
||||
pub async fn register_session(
|
||||
payload: GameplayReviewSessionInput,
|
||||
) -> Result<RpcOutcome<GameplayReviewSession>, String> {
|
||||
debug!(
|
||||
"[gameplay_review][rpc] register_session start game_id={} frames={} spoiler_mode={}",
|
||||
payload.game_id,
|
||||
payload.frames.len(),
|
||||
payload.spoiler_mode.unwrap_or_default().as_str()
|
||||
);
|
||||
let session = build_session(payload)?;
|
||||
let workspace_dir = workspace_dir().await?;
|
||||
debug!(
|
||||
"[gameplay_review][rpc] register_session workspace_dir={} session_id={}",
|
||||
workspace_dir.display(),
|
||||
session.session_id
|
||||
);
|
||||
store::save_session(&workspace_dir, &session)?;
|
||||
debug!(
|
||||
"[gameplay_review][rpc] register_session complete session_id={} game_id={}",
|
||||
session.session_id, session.game_id
|
||||
);
|
||||
Ok(RpcOutcome::single_log(
|
||||
session,
|
||||
"gameplay review session registered",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn analyze_session(
|
||||
payload: GameplayReviewAnalysisInput,
|
||||
) -> Result<RpcOutcome<GameplayReviewSession>, String> {
|
||||
debug!(
|
||||
"[gameplay_review][rpc] analyze_session start session_id={} max_highlights={:?} platform_overrides={}",
|
||||
payload.session_id,
|
||||
payload.max_highlights,
|
||||
payload.platforms.len()
|
||||
);
|
||||
let workspace_dir = workspace_dir().await?;
|
||||
let mut session = store::load_session(&workspace_dir, &payload.session_id)?
|
||||
.ok_or_else(|| format!("gameplay session not found: {}", payload.session_id))?;
|
||||
debug!(
|
||||
"[gameplay_review][rpc] analyze_session loaded session_id={} game_id={} frames={} preset_id={:?}",
|
||||
session.session_id,
|
||||
session.game_id,
|
||||
session.frames.len(),
|
||||
session.preset_id
|
||||
);
|
||||
let preset = match session.preset_id.as_deref() {
|
||||
Some(game_id) => {
|
||||
debug!(
|
||||
"[gameplay_review][rpc] analyze_session loading preset game_id={}",
|
||||
game_id
|
||||
);
|
||||
store::load_preset(&workspace_dir, game_id)?
|
||||
}
|
||||
None => {
|
||||
trace!(
|
||||
"[gameplay_review][rpc] analyze_session no preset configured session_id={}",
|
||||
session.session_id
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let analysis = analyze_session_frames(
|
||||
&session,
|
||||
preset.as_ref(),
|
||||
payload.max_highlights,
|
||||
&payload.platforms,
|
||||
)
|
||||
.await;
|
||||
session.analyzed_at_ms = Some(Utc::now().timestamp_millis());
|
||||
session.analysis = Some(analysis);
|
||||
store::save_session(&workspace_dir, &session)?;
|
||||
debug!(
|
||||
"[gameplay_review][rpc] analyze_session complete session_id={} highlights={} clips={} drafts={}",
|
||||
session.session_id,
|
||||
session
|
||||
.analysis
|
||||
.as_ref()
|
||||
.map(|analysis| analysis.highlights.len())
|
||||
.unwrap_or(0),
|
||||
session
|
||||
.analysis
|
||||
.as_ref()
|
||||
.map(|analysis| analysis.clip_candidates.len())
|
||||
.unwrap_or(0),
|
||||
session
|
||||
.analysis
|
||||
.as_ref()
|
||||
.map(|analysis| analysis.draft_metadata.len())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
Ok(RpcOutcome::single_log(
|
||||
session,
|
||||
"gameplay review session analyzed",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_session(session_id: String) -> Result<RpcOutcome<GameplayReviewSession>, String> {
|
||||
debug!(
|
||||
"[gameplay_review][rpc] get_session start session_id={}",
|
||||
session_id
|
||||
);
|
||||
let workspace_dir = workspace_dir().await?;
|
||||
let session = store::load_session(&workspace_dir, &session_id)?
|
||||
.ok_or_else(|| format!("gameplay session not found: {session_id}"))?;
|
||||
debug!(
|
||||
"[gameplay_review][rpc] get_session complete session_id={} game_id={} analyzed={}",
|
||||
session.session_id,
|
||||
session.game_id,
|
||||
session.analysis.is_some()
|
||||
);
|
||||
Ok(RpcOutcome::single_log(
|
||||
session,
|
||||
"gameplay review session fetched",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_sessions(
|
||||
game_id: Option<String>,
|
||||
) -> Result<RpcOutcome<Vec<GameplayReviewSession>>, String> {
|
||||
debug!(
|
||||
"[gameplay_review][rpc] list_sessions start game_id_filter={:?}",
|
||||
game_id
|
||||
);
|
||||
let workspace_dir = workspace_dir().await?;
|
||||
let mut sessions = store::list_sessions(&workspace_dir)?;
|
||||
if let Some(filter) = game_id.as_deref() {
|
||||
let before = sessions.len();
|
||||
sessions.retain(|session| session.game_id == filter);
|
||||
debug!(
|
||||
"[gameplay_review][rpc] list_sessions filtered game_id={} before={} after={}",
|
||||
filter,
|
||||
before,
|
||||
sessions.len()
|
||||
);
|
||||
}
|
||||
debug!(
|
||||
"[gameplay_review][rpc] list_sessions complete count={}",
|
||||
sessions.len()
|
||||
);
|
||||
Ok(RpcOutcome::single_log(
|
||||
sessions,
|
||||
"gameplay review sessions listed",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn set_preset(
|
||||
payload: GameplayPresetInput,
|
||||
) -> Result<RpcOutcome<GameplayReviewPreset>, String> {
|
||||
debug!(
|
||||
"[gameplay_review][rpc] set_preset start game_id={} display_name={} focus_items={} spoiler_mode={}",
|
||||
payload.game_id,
|
||||
payload.display_name,
|
||||
payload.coaching_focus.len(),
|
||||
payload.spoiler_mode.as_str()
|
||||
);
|
||||
let workspace_dir = workspace_dir().await?;
|
||||
let preset = store::preset_from_input(payload);
|
||||
store::save_preset(&workspace_dir, &preset)?;
|
||||
debug!(
|
||||
"[gameplay_review][rpc] set_preset complete game_id={} path_hint={}",
|
||||
preset.game_id,
|
||||
store::preset_path(&workspace_dir, &preset.game_id).display()
|
||||
);
|
||||
Ok(RpcOutcome::single_log(
|
||||
preset,
|
||||
"gameplay review preset saved",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_presets() -> Result<RpcOutcome<Vec<GameplayReviewPreset>>, String> {
|
||||
debug!("[gameplay_review][rpc] list_presets start");
|
||||
let workspace_dir = workspace_dir().await?;
|
||||
let presets = store::list_presets(&workspace_dir)?;
|
||||
debug!(
|
||||
"[gameplay_review][rpc] list_presets complete count={}",
|
||||
presets.len()
|
||||
);
|
||||
Ok(RpcOutcome::single_log(
|
||||
presets,
|
||||
"gameplay review presets listed",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn ask_session(
|
||||
payload: GameplayReviewQuestionInput,
|
||||
) -> Result<RpcOutcome<GameplayReviewQuestionResult>, String> {
|
||||
debug!(
|
||||
"[gameplay_review][rpc] ask_session start session_id={} question_len={}",
|
||||
payload.session_id,
|
||||
payload.question.len()
|
||||
);
|
||||
let workspace_dir = workspace_dir().await?;
|
||||
let session = store::load_session(&workspace_dir, &payload.session_id)?
|
||||
.ok_or_else(|| format!("gameplay session not found: {}", payload.session_id))?;
|
||||
let analysis = session
|
||||
.analysis
|
||||
.clone()
|
||||
.unwrap_or_else(|| GameplayReviewAnalysis {
|
||||
recap: build_recap(&session, &[], None),
|
||||
highlights: Vec::new(),
|
||||
clip_candidates: Vec::new(),
|
||||
draft_metadata: Vec::new(),
|
||||
follow_up_questions: default_follow_up_questions(&session),
|
||||
spoiler_note: None,
|
||||
});
|
||||
let answer = answer_question(&session, analysis.clone(), &payload.question);
|
||||
let matched_highlights = matched_highlights(&analysis.highlights, &payload.question);
|
||||
let suggested_follow_up = if analysis.follow_up_questions.is_empty() {
|
||||
default_follow_up_questions(&session)
|
||||
} else {
|
||||
analysis.follow_up_questions.clone()
|
||||
};
|
||||
debug!(
|
||||
"[gameplay_review][rpc] ask_session complete session_id={} matched_highlights={} suggested_follow_up={}",
|
||||
session.session_id,
|
||||
matched_highlights.len(),
|
||||
suggested_follow_up.len()
|
||||
);
|
||||
Ok(RpcOutcome::single_log(
|
||||
GameplayReviewQuestionResult {
|
||||
answer,
|
||||
matched_highlights,
|
||||
suggested_follow_up,
|
||||
},
|
||||
"gameplay review question answered",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn draft_clip_metadata(
|
||||
payload: GameplayReviewClipInput,
|
||||
) -> Result<RpcOutcome<Vec<GameplayPlatformDraft>>, String> {
|
||||
debug!(
|
||||
"[gameplay_review][rpc] draft_clip_metadata start session_id={} platform={:?} highlight_id={:?}",
|
||||
payload.session_id,
|
||||
payload.platform,
|
||||
payload.highlight_id
|
||||
);
|
||||
let workspace_dir = workspace_dir().await?;
|
||||
let session = store::load_session(&workspace_dir, &payload.session_id)?
|
||||
.ok_or_else(|| format!("gameplay session not found: {}", payload.session_id))?;
|
||||
let analysis = session
|
||||
.analysis
|
||||
.as_ref()
|
||||
.ok_or_else(|| "session has not been analyzed yet".to_string())?;
|
||||
let drafts = draft_metadata(
|
||||
&session,
|
||||
analysis,
|
||||
payload.platform.as_deref(),
|
||||
payload.highlight_id.as_deref(),
|
||||
);
|
||||
debug!(
|
||||
"[gameplay_review][rpc] draft_clip_metadata complete session_id={} drafts={}",
|
||||
session.session_id,
|
||||
drafts.len()
|
||||
);
|
||||
Ok(RpcOutcome::single_log(
|
||||
drafts,
|
||||
"gameplay review clip metadata drafted",
|
||||
))
|
||||
}
|
||||
|
||||
async fn workspace_dir() -> Result<std::path::PathBuf, String> {
|
||||
debug!("[gameplay_review][rpc] workspace_dir load start");
|
||||
let config = Config::load_or_init()
|
||||
.await
|
||||
.map_err(|err| format!("gameplay review config load failed: {err}"))?;
|
||||
let workspace_dir = store::workspace_dir_from_config(&config);
|
||||
trace!(
|
||||
"[gameplay_review][rpc] workspace_dir resolved path={}",
|
||||
workspace_dir.display()
|
||||
);
|
||||
Ok(workspace_dir)
|
||||
}
|
||||
|
||||
fn build_session(payload: GameplayReviewSessionInput) -> Result<GameplayReviewSession, String> {
|
||||
debug!(
|
||||
"[gameplay_review][rpc] build_session start game_id={} session_title={} frames={}",
|
||||
payload.game_id,
|
||||
payload.session_title,
|
||||
payload.frames.len()
|
||||
);
|
||||
if payload.game_id.trim().is_empty() {
|
||||
warn!("[gameplay_review][rpc] build_session rejected empty game_id");
|
||||
return Err("game_id is required".to_string());
|
||||
}
|
||||
if payload.session_title.trim().is_empty() {
|
||||
warn!("[gameplay_review][rpc] build_session rejected empty session_title");
|
||||
return Err("session_title is required".to_string());
|
||||
}
|
||||
if payload.frames.is_empty() {
|
||||
warn!("[gameplay_review][rpc] build_session rejected empty frames");
|
||||
return Err("at least one frame is required".to_string());
|
||||
}
|
||||
|
||||
let now = Utc::now().timestamp_millis();
|
||||
let session_id = format!(
|
||||
"gameplay-{}-{}",
|
||||
store::slugify(&payload.game_id),
|
||||
Uuid::new_v4().simple()
|
||||
);
|
||||
Ok(GameplayReviewSession {
|
||||
session_id,
|
||||
game_id: payload.game_id.trim().to_string(),
|
||||
session_title: payload.session_title.trim().to_string(),
|
||||
source_label: payload
|
||||
.source_label
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
spoiler_mode: payload.spoiler_mode.unwrap_or_default(),
|
||||
preset_id: payload
|
||||
.preset_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
imported_at_ms: now,
|
||||
analyzed_at_ms: None,
|
||||
frames: payload.frames,
|
||||
analysis: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn analyze_session_frames(
|
||||
session: &GameplayReviewSession,
|
||||
preset: Option<&GameplayReviewPreset>,
|
||||
max_highlights: Option<usize>,
|
||||
platform_overrides: &[String],
|
||||
) -> GameplayReviewAnalysis {
|
||||
let limit = max_highlights.unwrap_or(5).clamp(1, 8);
|
||||
let platforms: Vec<String> = if platform_overrides.is_empty() {
|
||||
DEFAULT_PLATFORMS
|
||||
.iter()
|
||||
.map(|platform| (*platform).to_string())
|
||||
.collect()
|
||||
} else {
|
||||
platform_overrides.to_vec()
|
||||
};
|
||||
debug!(
|
||||
"[gameplay_review][analysis] start session_id={} game_id={} frames={} limit={} platforms={}",
|
||||
session.session_id,
|
||||
session.game_id,
|
||||
session.frames.len(),
|
||||
limit,
|
||||
platforms.len()
|
||||
);
|
||||
if let Some(preset) = preset {
|
||||
trace!(
|
||||
"[gameplay_review][analysis] using preset game_id={} coaching_focus={} audio_feedback={} spoiler_mode={}",
|
||||
preset.game_id,
|
||||
preset.coaching_focus.len(),
|
||||
preset.audio_feedback,
|
||||
preset.spoiler_mode.as_str()
|
||||
);
|
||||
} else {
|
||||
trace!(
|
||||
"[gameplay_review][analysis] no preset for session_id={}",
|
||||
session.session_id
|
||||
);
|
||||
}
|
||||
|
||||
let mut highlights = Vec::new();
|
||||
let mut clip_candidates = Vec::new();
|
||||
|
||||
for (index, frame) in session.frames.iter().enumerate() {
|
||||
trace!(
|
||||
"[gameplay_review][analysis] analyze frame session_id={} frame_index={} file_name={}",
|
||||
session.session_id,
|
||||
index,
|
||||
frame.file_name
|
||||
);
|
||||
let capture = CaptureFrame {
|
||||
captured_at_ms: frame
|
||||
.captured_at_ms
|
||||
.unwrap_or(session.imported_at_ms.saturating_add((index as i64) * 1000)),
|
||||
reason: "gameplay_review_import".to_string(),
|
||||
app_name: Some(session.game_id.clone()),
|
||||
window_title: Some(session.session_title.clone()),
|
||||
image_ref: Some(frame.image_ref.clone()),
|
||||
};
|
||||
|
||||
let summary = match global_engine().analyze_and_persist_frame(capture).await {
|
||||
Ok(summary) => summary,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"[gameplay_review][analysis] frame analysis fallback session_id={} frame_index={} error={}",
|
||||
session.session_id,
|
||||
index,
|
||||
err
|
||||
);
|
||||
fallback_summary(session, frame, index)
|
||||
}
|
||||
};
|
||||
|
||||
let kind = classify_kind(
|
||||
&summary.key_text,
|
||||
&summary.actionable_notes,
|
||||
session.spoiler_mode,
|
||||
);
|
||||
let title = highlight_title(&summary.key_text, &frame.file_name, kind);
|
||||
let rationale = if summary.actionable_notes.trim().is_empty() {
|
||||
summary.key_text.clone()
|
||||
} else {
|
||||
summary.actionable_notes.clone()
|
||||
};
|
||||
let confidence = summary.confidence.clamp(0.0, 1.0);
|
||||
let highlight = GameplayHighlight {
|
||||
id: format!("{}-{}", session.session_id, index),
|
||||
frame_index: index,
|
||||
captured_at_ms: Some(summary.captured_at_ms),
|
||||
title,
|
||||
rationale,
|
||||
confidence,
|
||||
kind,
|
||||
};
|
||||
highlights.push(highlight);
|
||||
}
|
||||
|
||||
highlights.sort_by(|left, right| {
|
||||
right
|
||||
.confidence
|
||||
.partial_cmp(&left.confidence)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
.then(left.frame_index.cmp(&right.frame_index))
|
||||
});
|
||||
highlights.truncate(limit);
|
||||
|
||||
for highlight in &highlights {
|
||||
clip_candidates.push(clip_candidate_from_highlight(session, highlight));
|
||||
}
|
||||
|
||||
let recap = build_recap(session, &highlights, preset);
|
||||
let draft_metadata = draft_metadata_from_highlights(session, &highlights, preset, &platforms);
|
||||
debug!(
|
||||
"[gameplay_review][analysis] complete session_id={} highlights={} clips={} drafts={}",
|
||||
session.session_id,
|
||||
highlights.len(),
|
||||
clip_candidates.len(),
|
||||
draft_metadata.len()
|
||||
);
|
||||
|
||||
GameplayReviewAnalysis {
|
||||
recap,
|
||||
highlights,
|
||||
clip_candidates,
|
||||
draft_metadata,
|
||||
follow_up_questions: default_follow_up_questions(session),
|
||||
spoiler_note: spoiler_note(session.spoiler_mode),
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_summary(
|
||||
session: &GameplayReviewSession,
|
||||
frame: &GameplayFrameInput,
|
||||
index: usize,
|
||||
) -> crate::openhuman::screen_intelligence::VisionSummary {
|
||||
trace!(
|
||||
"[gameplay_review][analysis] fallback_summary session_id={} frame_index={} file_name={}",
|
||||
session.session_id,
|
||||
index,
|
||||
frame.file_name
|
||||
);
|
||||
let label = frame
|
||||
.file_name
|
||||
.rsplit_once('/')
|
||||
.map(|(_, tail)| tail)
|
||||
.unwrap_or(&frame.file_name)
|
||||
.trim();
|
||||
let captured_at_ms = frame
|
||||
.captured_at_ms
|
||||
.unwrap_or(session.imported_at_ms.saturating_add((index as i64) * 1000));
|
||||
crate::openhuman::screen_intelligence::VisionSummary {
|
||||
id: format!("gameplay-{}-{}", captured_at_ms, Uuid::new_v4()),
|
||||
captured_at_ms,
|
||||
app_name: Some(session.game_id.clone()),
|
||||
window_title: Some(session.session_title.clone()),
|
||||
ui_state: format!("Gameplay frame {index}: {label}"),
|
||||
key_text: format!("Gameplay moment from {}", label),
|
||||
actionable_notes: if session.spoiler_mode == SpoilerMode::Off {
|
||||
"Keep the coaching spoiler-safe.".to_string()
|
||||
} else {
|
||||
"Review the clip for highlights and mistakes.".to_string()
|
||||
},
|
||||
confidence: 0.65,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_kind(key_text: &str, notes: &str, spoiler_mode: SpoilerMode) -> HighlightKind {
|
||||
let text = format!("{} {}", key_text, notes).to_ascii_lowercase();
|
||||
if text.contains("mistake") || text.contains("miss") || text.contains("throw") {
|
||||
return HighlightKind::Mistake;
|
||||
}
|
||||
if text.contains("clutch")
|
||||
|| text.contains("highlight")
|
||||
|| text.contains("fight")
|
||||
|| text.contains("kill")
|
||||
{
|
||||
return HighlightKind::Highlight;
|
||||
}
|
||||
if matches!(spoiler_mode, SpoilerMode::Off) && text.contains("story") {
|
||||
return HighlightKind::Coaching;
|
||||
}
|
||||
HighlightKind::Coaching
|
||||
}
|
||||
|
||||
fn highlight_title(key_text: &str, file_name: &str, kind: HighlightKind) -> String {
|
||||
let source = if key_text.trim().is_empty() {
|
||||
file_name
|
||||
} else {
|
||||
key_text
|
||||
};
|
||||
let base = source
|
||||
.split(['.', '!', '?', '\n'])
|
||||
.next()
|
||||
.unwrap_or(source)
|
||||
.trim();
|
||||
let prefix = match kind {
|
||||
HighlightKind::Highlight => "Highlight",
|
||||
HighlightKind::Mistake => "Mistake",
|
||||
HighlightKind::Coaching => "Review",
|
||||
};
|
||||
format!("{prefix}: {}", truncate(base, 72))
|
||||
}
|
||||
|
||||
fn clip_candidate_from_highlight(
|
||||
session: &GameplayReviewSession,
|
||||
highlight: &GameplayHighlight,
|
||||
) -> GameplayClipCandidate {
|
||||
let start_index = highlight.frame_index.saturating_sub(1);
|
||||
let end_index = (highlight.frame_index + 1).min(session.frames.len().saturating_sub(1));
|
||||
GameplayClipCandidate {
|
||||
id: format!("clip-{}", highlight.id),
|
||||
frame_index: highlight.frame_index,
|
||||
start_label: session
|
||||
.frames
|
||||
.get(start_index)
|
||||
.map(|frame| frame.file_name.clone())
|
||||
.unwrap_or_else(|| format!("frame-{start_index}")),
|
||||
end_label: session
|
||||
.frames
|
||||
.get(end_index)
|
||||
.map(|frame| frame.file_name.clone())
|
||||
.unwrap_or_else(|| format!("frame-{end_index}")),
|
||||
rationale: highlight.rationale.clone(),
|
||||
confidence: highlight.confidence,
|
||||
}
|
||||
}
|
||||
|
||||
fn draft_metadata(
|
||||
session: &GameplayReviewSession,
|
||||
analysis: &GameplayReviewAnalysis,
|
||||
platform: Option<&str>,
|
||||
highlight_id: Option<&str>,
|
||||
) -> Vec<GameplayPlatformDraft> {
|
||||
let filtered = highlight_id
|
||||
.and_then(|needle| {
|
||||
analysis
|
||||
.highlights
|
||||
.iter()
|
||||
.find(|highlight| highlight.id == needle)
|
||||
})
|
||||
.cloned()
|
||||
.or_else(|| analysis.highlights.first().cloned());
|
||||
let highlights = filtered.into_iter().collect::<Vec<_>>();
|
||||
let platforms = match platform {
|
||||
Some(value) if !value.trim().is_empty() => vec![value.trim().to_string()],
|
||||
_ => DEFAULT_PLATFORMS
|
||||
.iter()
|
||||
.map(|platform| (*platform).to_string())
|
||||
.collect(),
|
||||
};
|
||||
draft_metadata_from_highlights(session, &highlights, None, &platforms)
|
||||
}
|
||||
|
||||
fn draft_metadata_from_highlights(
|
||||
session: &GameplayReviewSession,
|
||||
highlights: &[GameplayHighlight],
|
||||
preset: Option<&GameplayReviewPreset>,
|
||||
platforms: &[String],
|
||||
) -> Vec<GameplayPlatformDraft> {
|
||||
let best = highlights.first();
|
||||
let best_title = best
|
||||
.map(|highlight| highlight.title.clone())
|
||||
.unwrap_or_else(|| format!("{} recap", session.session_title));
|
||||
let focus = preset
|
||||
.and_then(|preset| preset.coaching_focus.first())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "clean execution".to_string());
|
||||
|
||||
platforms
|
||||
.iter()
|
||||
.map(|platform| GameplayPlatformDraft {
|
||||
platform: platform.clone(),
|
||||
title: format!(
|
||||
"{} — {} ({})",
|
||||
session.game_id,
|
||||
truncate(&best_title, 64),
|
||||
platform
|
||||
),
|
||||
description: format!(
|
||||
"Session: {}\nFocus: {}\nTop moment: {}\nSpoiler mode: {}",
|
||||
session.session_title,
|
||||
focus,
|
||||
best.map(|highlight| highlight.rationale.clone())
|
||||
.unwrap_or_else(|| "No highlight selected yet.".to_string()),
|
||||
session.spoiler_mode.as_str(),
|
||||
),
|
||||
tags: build_tags(session, best, preset, platform),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_tags(
|
||||
session: &GameplayReviewSession,
|
||||
highlight: Option<&GameplayHighlight>,
|
||||
preset: Option<&GameplayReviewPreset>,
|
||||
platform: &str,
|
||||
) -> Vec<String> {
|
||||
let mut tags = BTreeSet::new();
|
||||
tags.insert(store::slugify(&session.game_id));
|
||||
tags.insert(store::slugify(platform));
|
||||
if let Some(highlight) = highlight {
|
||||
tags.insert(store::slugify(&highlight.title));
|
||||
}
|
||||
if let Some(preset) = preset {
|
||||
for focus in &preset.coaching_focus {
|
||||
tags.insert(store::slugify(focus));
|
||||
}
|
||||
}
|
||||
tags.into_iter().take(6).collect()
|
||||
}
|
||||
|
||||
fn build_recap(
|
||||
session: &GameplayReviewSession,
|
||||
highlights: &[GameplayHighlight],
|
||||
preset: Option<&GameplayReviewPreset>,
|
||||
) -> String {
|
||||
let mut recap = format!(
|
||||
"Gameplay recap for {} ({})\nSpoiler mode: {}\nFrames reviewed: {}",
|
||||
session.session_title,
|
||||
session.game_id,
|
||||
session.spoiler_mode.as_str(),
|
||||
session.frames.len()
|
||||
);
|
||||
if let Some(preset) = preset {
|
||||
recap.push_str(&format!("\nPreset: {}", preset.display_name));
|
||||
if !preset.coaching_focus.is_empty() {
|
||||
recap.push_str(&format!(
|
||||
"\nCoaching focus: {}",
|
||||
preset.coaching_focus.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
if highlights.is_empty() {
|
||||
recap.push_str("\nNo strong highlights were detected yet.");
|
||||
} else {
|
||||
recap.push_str("\nTop moments:");
|
||||
for highlight in highlights.iter().take(5) {
|
||||
recap.push_str(&format!(
|
||||
"\n- [{}] {} — {}",
|
||||
highlight.kind.as_str(),
|
||||
highlight.title,
|
||||
highlight.rationale
|
||||
));
|
||||
}
|
||||
}
|
||||
recap
|
||||
}
|
||||
|
||||
fn default_follow_up_questions(session: &GameplayReviewSession) -> Vec<String> {
|
||||
vec![
|
||||
format!("What were my best moments in {}?", session.session_title),
|
||||
format!("Where did I make mistakes in {}?", session.session_title),
|
||||
format!("What should I post from this {} session?", session.game_id),
|
||||
]
|
||||
}
|
||||
|
||||
fn spoiler_note(mode: SpoilerMode) -> Option<String> {
|
||||
match mode {
|
||||
SpoilerMode::Off => Some("Spoiler-safe mode is enabled; avoid story reveals.".to_string()),
|
||||
SpoilerMode::Light => {
|
||||
Some("Light spoiler filtering is enabled; keep story beats vague.".to_string())
|
||||
}
|
||||
SpoilerMode::Full => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn matched_highlights(highlights: &[GameplayHighlight], question: &str) -> Vec<GameplayHighlight> {
|
||||
let lowered = question.to_ascii_lowercase();
|
||||
let wants_mistakes =
|
||||
lowered.contains("mistake") || lowered.contains("throw") || lowered.contains("bad");
|
||||
let wants_highlights = lowered.contains("best")
|
||||
|| lowered.contains("highlight")
|
||||
|| lowered.contains("clip")
|
||||
|| lowered.contains("post");
|
||||
highlights
|
||||
.iter()
|
||||
.filter(|highlight| {
|
||||
(wants_mistakes
|
||||
&& matches!(
|
||||
highlight.kind,
|
||||
HighlightKind::Mistake | HighlightKind::Coaching
|
||||
))
|
||||
|| (wants_highlights && matches!(highlight.kind, HighlightKind::Highlight))
|
||||
|| (!wants_mistakes && !wants_highlights)
|
||||
})
|
||||
.take(3)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn answer_question(
|
||||
session: &GameplayReviewSession,
|
||||
analysis: GameplayReviewAnalysis,
|
||||
question: &str,
|
||||
) -> String {
|
||||
let lowered = question.to_ascii_lowercase();
|
||||
if lowered.contains("best") || lowered.contains("highlight") || lowered.contains("clip") {
|
||||
if let Some(highlight) = analysis.highlights.first() {
|
||||
return format!(
|
||||
"Best clip candidate for {}: {} — {}",
|
||||
session.session_title, highlight.title, highlight.rationale
|
||||
);
|
||||
}
|
||||
}
|
||||
if lowered.contains("mistake") || lowered.contains("throw") || lowered.contains("miss") {
|
||||
if let Some(highlight) = analysis.highlights.iter().find(|highlight| {
|
||||
matches!(
|
||||
highlight.kind,
|
||||
HighlightKind::Mistake | HighlightKind::Coaching
|
||||
)
|
||||
}) {
|
||||
return format!(
|
||||
"Review point for {}: {} — {}",
|
||||
session.session_title, highlight.title, highlight.rationale
|
||||
);
|
||||
}
|
||||
}
|
||||
if lowered.contains("post")
|
||||
|| lowered.contains("title")
|
||||
|| lowered.contains("description")
|
||||
|| lowered.contains("tags")
|
||||
{
|
||||
if let Some(draft) = analysis.draft_metadata.first() {
|
||||
return format!(
|
||||
"{} draft for {}: {}. Tags: {}",
|
||||
draft.platform,
|
||||
session.session_title,
|
||||
draft.title,
|
||||
draft.tags.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
format!("{}\n\n{}", analysis.recap, question)
|
||||
}
|
||||
|
||||
fn truncate(value: &str, max_chars: usize) -> String {
|
||||
let chars: Vec<char> = value.chars().collect();
|
||||
if chars.len() <= max_chars {
|
||||
value.to_string()
|
||||
} else {
|
||||
chars[..max_chars].iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl HighlightKind {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Highlight => "highlight",
|
||||
Self::Mistake => "mistake",
|
||||
Self::Coaching => "coaching",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::config::TEST_ENV_LOCK as ENV_LOCK;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn mock_frame(name: &str, image_ref: &str, captured_at_ms: i64) -> GameplayFrameInput {
|
||||
GameplayFrameInput {
|
||||
file_name: name.to_string(),
|
||||
image_ref: image_ref.to_string(),
|
||||
captured_at_ms: Some(captured_at_ms),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_and_list_sessions_round_trip() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let tmp = tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
|
||||
}
|
||||
|
||||
let registered = register_session(GameplayReviewSessionInput {
|
||||
game_id: "Apex Legends".to_string(),
|
||||
session_title: "Ranked climb".to_string(),
|
||||
source_label: Some("/recordings/apex".to_string()),
|
||||
spoiler_mode: Some(SpoilerMode::Light),
|
||||
preset_id: None,
|
||||
frames: vec![mock_frame("frame1.png", "data:image/png;base64,AAA", 1000)],
|
||||
})
|
||||
.await
|
||||
.expect("register")
|
||||
.value;
|
||||
|
||||
assert_eq!(registered.game_id, "Apex Legends");
|
||||
let listed = list_sessions(None).await.expect("list").value;
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].session_id, registered.session_id);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_WORKSPACE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_matching_prefers_highlights_for_best_prompt() {
|
||||
let session = GameplayReviewSession {
|
||||
session_id: "session-1".to_string(),
|
||||
game_id: "Game".to_string(),
|
||||
session_title: "Session".to_string(),
|
||||
source_label: None,
|
||||
spoiler_mode: SpoilerMode::Light,
|
||||
preset_id: None,
|
||||
imported_at_ms: 1000,
|
||||
analyzed_at_ms: None,
|
||||
frames: vec![],
|
||||
analysis: None,
|
||||
};
|
||||
let analysis = GameplayReviewAnalysis {
|
||||
recap: "recap".to_string(),
|
||||
highlights: vec![GameplayHighlight {
|
||||
id: "h1".to_string(),
|
||||
frame_index: 0,
|
||||
captured_at_ms: Some(1),
|
||||
title: "Clutch finish".to_string(),
|
||||
rationale: "A clean finish".to_string(),
|
||||
confidence: 0.95,
|
||||
kind: HighlightKind::Highlight,
|
||||
}],
|
||||
clip_candidates: Vec::new(),
|
||||
draft_metadata: Vec::new(),
|
||||
follow_up_questions: vec!["What was the turning point?".to_string()],
|
||||
spoiler_note: None,
|
||||
};
|
||||
let answer = answer_question(&session, analysis.clone(), "What were my best plays?");
|
||||
assert!(answer.contains("Clutch finish"));
|
||||
assert_eq!(matched_highlights(&analysis.highlights, "best").len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use log::debug;
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::types::{
|
||||
GameplayPresetInput, GameplayReviewAnalysisInput, GameplayReviewClipInput,
|
||||
GameplayReviewQuestionInput, GameplayReviewSessionInput,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SessionIdParams {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("register_session"),
|
||||
schemas("analyze_session"),
|
||||
schemas("get_session"),
|
||||
schemas("list_sessions"),
|
||||
schemas("set_preset"),
|
||||
schemas("list_presets"),
|
||||
schemas("ask_session"),
|
||||
schemas("draft_clip_metadata"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schemas("register_session"),
|
||||
handler: handle_register_session,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("analyze_session"),
|
||||
handler: handle_analyze_session,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_session"),
|
||||
handler: handle_get_session,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("list_sessions"),
|
||||
handler: handle_list_sessions,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("set_preset"),
|
||||
handler: handle_set_preset,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("list_presets"),
|
||||
handler: handle_list_presets,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("ask_session"),
|
||||
handler: handle_ask_session,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("draft_clip_metadata"),
|
||||
handler: handle_draft_clip_metadata,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"register_session" => ControllerSchema {
|
||||
namespace: "gameplay_review",
|
||||
function: "register_session",
|
||||
description: "Register a gameplay session from imported keyframes.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "game_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Game identifier.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "session_title",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Human-readable session title.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "source_label",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional label describing where the footage came from.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "spoiler_mode",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Ref("SpoilerMode"))),
|
||||
comment: "Optional spoiler-mode override for this session.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "preset_id",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional coaching preset to apply.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "frames",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("GameplayFrameInput"))),
|
||||
comment: "Captured keyframes for the session.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![json_output("session", "Stored gameplay review session.")],
|
||||
},
|
||||
"analyze_session" => ControllerSchema {
|
||||
namespace: "gameplay_review",
|
||||
function: "analyze_session",
|
||||
description:
|
||||
"Analyze a gameplay session, generate highlights, and draft clip metadata.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "session_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Session identifier to analyze.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "max_highlights",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Optional cap on the number of highlights to generate.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "platforms",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
|
||||
comment: "Target platforms for clip drafts (e.g. youtube, tiktok).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![json_output("session", "Analyzed gameplay review session.")],
|
||||
},
|
||||
"get_session" => ControllerSchema {
|
||||
namespace: "gameplay_review",
|
||||
function: "get_session",
|
||||
description: "Fetch one gameplay review session by id.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "session_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Session identifier.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![json_output("session", "Gameplay review session.")],
|
||||
},
|
||||
"list_sessions" => ControllerSchema {
|
||||
namespace: "gameplay_review",
|
||||
function: "list_sessions",
|
||||
description: "List gameplay review sessions stored in the workspace.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "game_id",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional game filter.",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![json_output("sessions", "Gameplay review sessions.")],
|
||||
},
|
||||
"set_preset" => ControllerSchema {
|
||||
namespace: "gameplay_review",
|
||||
function: "set_preset",
|
||||
description: "Save a game-specific coaching preset.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "game_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Game identifier.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "display_name",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Human-readable preset name.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "coaching_focus",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
|
||||
comment: "Areas of focus for coaching commentary.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "audio_feedback",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Whether to surface audio cues in highlight summaries.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "spoiler_mode",
|
||||
ty: TypeSchema::Ref("SpoilerMode"),
|
||||
comment: "Spoiler-handling mode for this preset.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "notes",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional free-form preset notes.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![json_output("preset", "Saved gameplay review preset.")],
|
||||
},
|
||||
"list_presets" => ControllerSchema {
|
||||
namespace: "gameplay_review",
|
||||
function: "list_presets",
|
||||
description: "List stored gameplay coaching presets.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output("presets", "Gameplay review presets.")],
|
||||
},
|
||||
"ask_session" => ControllerSchema {
|
||||
namespace: "gameplay_review",
|
||||
function: "ask_session",
|
||||
description: "Ask a question against a stored gameplay session.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "session_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Session identifier to query.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "question",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Question text to ask against the session.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![json_output(
|
||||
"answer",
|
||||
"Question answer with matched highlights.",
|
||||
)],
|
||||
},
|
||||
"draft_clip_metadata" => ControllerSchema {
|
||||
namespace: "gameplay_review",
|
||||
function: "draft_clip_metadata",
|
||||
description: "Draft clip titles, descriptions, and tags for one gameplay highlight.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "session_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Session identifier holding the highlight.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "platform",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional target platform (defaults to all configured).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "highlight_id",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional highlight to focus on (defaults to all).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![json_output("drafts", "Draft metadata for clip publishing.")],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "gameplay_review",
|
||||
function: "unknown",
|
||||
description: "Unknown gameplay_review controller function.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output("error", "Lookup error details.")],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::Json,
|
||||
comment,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
|
||||
serde_json::from_value(Value::Object(params)).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn handle_register_session(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<GameplayReviewSessionInput>(params)?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] register_session game_id={} frames={}",
|
||||
payload.game_id,
|
||||
payload.frames.len()
|
||||
);
|
||||
let result = crate::openhuman::gameplay_review::rpc::register_session(payload).await?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] register_session complete session_id={} game_id={}",
|
||||
result.value.session_id, result.value.game_id
|
||||
);
|
||||
to_json(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_analyze_session(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<GameplayReviewAnalysisInput>(params)?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] analyze_session session_id={} max_highlights={:?} platforms={}",
|
||||
payload.session_id,
|
||||
payload.max_highlights,
|
||||
payload.platforms.len()
|
||||
);
|
||||
let result = crate::openhuman::gameplay_review::rpc::analyze_session(payload).await?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] analyze_session complete session_id={} analyzed={}",
|
||||
result.value.session_id,
|
||||
result.value.analyzed_at_ms.is_some()
|
||||
);
|
||||
to_json(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get_session(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<SessionIdParams>(params)?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] get_session session_id={}",
|
||||
payload.session_id
|
||||
);
|
||||
let result =
|
||||
crate::openhuman::gameplay_review::rpc::get_session(payload.session_id).await?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] get_session complete session_id={} game_id={}",
|
||||
result.value.session_id, result.value.game_id
|
||||
);
|
||||
to_json(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_sessions(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
#[derive(Deserialize)]
|
||||
struct Params {
|
||||
#[serde(default)]
|
||||
game_id: Option<String>,
|
||||
}
|
||||
let payload = deserialize_params::<Params>(params)?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] list_sessions game_id_filter={:?}",
|
||||
payload.game_id
|
||||
);
|
||||
let result = crate::openhuman::gameplay_review::rpc::list_sessions(payload.game_id).await?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] list_sessions complete count={}",
|
||||
result.value.len()
|
||||
);
|
||||
to_json(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_set_preset(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<GameplayPresetInput>(params)?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] set_preset game_id={} display_name={} focus_items={}",
|
||||
payload.game_id,
|
||||
payload.display_name,
|
||||
payload.coaching_focus.len()
|
||||
);
|
||||
let result = crate::openhuman::gameplay_review::rpc::set_preset(payload).await?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] set_preset complete game_id={}",
|
||||
result.value.game_id
|
||||
);
|
||||
to_json(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_presets(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
debug!("[gameplay_review][controller] list_presets start");
|
||||
let result = crate::openhuman::gameplay_review::rpc::list_presets().await?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] list_presets complete count={}",
|
||||
result.value.len()
|
||||
);
|
||||
to_json(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_ask_session(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<GameplayReviewQuestionInput>(params)?;
|
||||
let session_id = payload.session_id.clone();
|
||||
debug!(
|
||||
"[gameplay_review][controller] ask_session session_id={} question_len={}",
|
||||
session_id,
|
||||
payload.question.len()
|
||||
);
|
||||
let result = crate::openhuman::gameplay_review::rpc::ask_session(payload).await?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] ask_session complete session_id={} matched_highlights={} suggested_follow_up={}",
|
||||
session_id,
|
||||
result.value.matched_highlights.len(),
|
||||
result.value.suggested_follow_up.len()
|
||||
);
|
||||
to_json(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_draft_clip_metadata(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<GameplayReviewClipInput>(params)?;
|
||||
let session_id = payload.session_id.clone();
|
||||
debug!(
|
||||
"[gameplay_review][controller] draft_clip_metadata session_id={} platform={:?} highlight_id={:?}",
|
||||
session_id,
|
||||
payload.platform,
|
||||
payload.highlight_id
|
||||
);
|
||||
let result = crate::openhuman::gameplay_review::rpc::draft_clip_metadata(payload).await?;
|
||||
debug!(
|
||||
"[gameplay_review][controller] draft_clip_metadata complete session_id={} drafts={}",
|
||||
session_id,
|
||||
result.value.len()
|
||||
);
|
||||
to_json(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use log::{debug, trace, warn};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
use super::types::{GameplayPresetInput, GameplayReviewPreset, GameplayReviewSession};
|
||||
|
||||
const REVIEW_DIR: &str = "gameplay_review";
|
||||
const SESSIONS_DIR: &str = "sessions";
|
||||
const PRESETS_DIR: &str = "presets";
|
||||
|
||||
pub fn review_root(workspace_dir: &Path) -> PathBuf {
|
||||
workspace_dir.join(REVIEW_DIR)
|
||||
}
|
||||
|
||||
pub fn sessions_dir(workspace_dir: &Path) -> PathBuf {
|
||||
review_root(workspace_dir).join(SESSIONS_DIR)
|
||||
}
|
||||
|
||||
pub fn presets_dir(workspace_dir: &Path) -> PathBuf {
|
||||
review_root(workspace_dir).join(PRESETS_DIR)
|
||||
}
|
||||
|
||||
pub fn slugify(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut last_was_dash = false;
|
||||
for ch in value.trim().to_ascii_lowercase().chars() {
|
||||
let next = if ch.is_ascii_alphanumeric() { ch } else { '-' };
|
||||
if next == '-' {
|
||||
if last_was_dash {
|
||||
continue;
|
||||
}
|
||||
last_was_dash = true;
|
||||
} else {
|
||||
last_was_dash = false;
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
let trimmed = out.trim_matches('-');
|
||||
if trimmed.is_empty() {
|
||||
"untitled".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_dirs(workspace_dir: &Path) -> Result<(), String> {
|
||||
debug!(
|
||||
"[gameplay_review][store] ensure_dirs workspace_dir={}",
|
||||
workspace_dir.display()
|
||||
);
|
||||
fs::create_dir_all(sessions_dir(workspace_dir))
|
||||
.map_err(|err| format!("failed to create gameplay review sessions dir: {err}"))?;
|
||||
fs::create_dir_all(presets_dir(workspace_dir))
|
||||
.map_err(|err| format!("failed to create gameplay review presets dir: {err}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json_atomic(path: &Path, entity: &str, payload: &str) -> Result<(), String> {
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
trace!(
|
||||
"[gameplay_review][store] write_json_atomic entity={} path={} tmp_path={}",
|
||||
entity,
|
||||
path.display(),
|
||||
tmp_path.display()
|
||||
);
|
||||
fs::write(&tmp_path, payload).map_err(|err| format!("failed to write {entity} tmp: {err}"))?;
|
||||
fs::rename(&tmp_path, path)
|
||||
.map_err(|err| format!("failed to move {entity} into place: {err}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns an error if `session_id` contains path separators or is empty,
|
||||
/// preventing path traversal when the id comes from an RPC caller.
|
||||
fn validate_session_id(session_id: &str) -> Result<(), String> {
|
||||
if session_id.is_empty() {
|
||||
return Err("session_id must not be empty".to_string());
|
||||
}
|
||||
if session_id.contains('/') || session_id.contains('\\') || session_id.contains("..") {
|
||||
return Err(format!(
|
||||
"session_id contains invalid characters: {session_id}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_path(workspace_dir: &Path, session_id: &str) -> PathBuf {
|
||||
sessions_dir(workspace_dir).join(format!("{session_id}.json"))
|
||||
}
|
||||
|
||||
pub fn preset_path(workspace_dir: &Path, game_id: &str) -> PathBuf {
|
||||
presets_dir(workspace_dir).join(format!("{}.json", slugify(game_id)))
|
||||
}
|
||||
|
||||
pub fn save_session(workspace_dir: &Path, session: &GameplayReviewSession) -> Result<(), String> {
|
||||
validate_session_id(&session.session_id)?;
|
||||
ensure_dirs(workspace_dir)?;
|
||||
let path = session_path(workspace_dir, &session.session_id);
|
||||
debug!(
|
||||
"[gameplay_review][store] save_session session_id={} game_id={} path={}",
|
||||
session.session_id,
|
||||
session.game_id,
|
||||
path.display()
|
||||
);
|
||||
let payload = serde_json::to_string_pretty(session)
|
||||
.map_err(|err| format!("failed to serialize session: {err}"))?;
|
||||
write_json_atomic(&path, "session", &payload)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_session(
|
||||
workspace_dir: &Path,
|
||||
session_id: &str,
|
||||
) -> Result<Option<GameplayReviewSession>, String> {
|
||||
validate_session_id(session_id)?;
|
||||
let path = session_path(workspace_dir, session_id);
|
||||
trace!(
|
||||
"[gameplay_review][store] load_session session_id={} path={}",
|
||||
session_id,
|
||||
path.display()
|
||||
);
|
||||
if !path.exists() {
|
||||
debug!(
|
||||
"[gameplay_review][store] load_session missing session_id={} path={}",
|
||||
session_id,
|
||||
path.display()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read_to_string(&path).map_err(|err| format!("failed to read session: {err}"))?;
|
||||
let session = serde_json::from_str(&raw)
|
||||
.map_err(|err| format!("failed to parse session {}: {err}", path.display()))?;
|
||||
debug!(
|
||||
"[gameplay_review][store] load_session loaded session_id={} path={}",
|
||||
session_id,
|
||||
path.display()
|
||||
);
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
pub fn list_sessions(workspace_dir: &Path) -> Result<Vec<GameplayReviewSession>, String> {
|
||||
let dir = sessions_dir(workspace_dir);
|
||||
trace!(
|
||||
"[gameplay_review][store] list_sessions dir={}",
|
||||
dir.display()
|
||||
);
|
||||
if !dir.exists() {
|
||||
debug!(
|
||||
"[gameplay_review][store] list_sessions empty dir={}",
|
||||
dir.display()
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for entry in fs::read_dir(&dir).map_err(|err| format!("failed to list sessions: {err}"))? {
|
||||
let entry = entry.map_err(|err| format!("failed to read session entry: {err}"))?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let raw = fs::read_to_string(&path)
|
||||
.map_err(|err| format!("failed to read session {}: {err}", path.display()))?;
|
||||
match serde_json::from_str::<GameplayReviewSession>(&raw) {
|
||||
Ok(session) => sessions.push(session),
|
||||
Err(err) => warn!(
|
||||
"[gameplay_review][store] list_sessions skipped invalid session path={} error={}",
|
||||
path.display(),
|
||||
err
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
sessions.sort_by(|left, right| {
|
||||
right
|
||||
.imported_at_ms
|
||||
.cmp(&left.imported_at_ms)
|
||||
.then(right.analyzed_at_ms.cmp(&left.analyzed_at_ms))
|
||||
});
|
||||
debug!(
|
||||
"[gameplay_review][store] list_sessions complete dir={} count={}",
|
||||
dir.display(),
|
||||
sessions.len()
|
||||
);
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
pub fn save_preset(workspace_dir: &Path, preset: &GameplayReviewPreset) -> Result<(), String> {
|
||||
ensure_dirs(workspace_dir)?;
|
||||
let path = preset_path(workspace_dir, &preset.game_id);
|
||||
debug!(
|
||||
"[gameplay_review][store] save_preset game_id={} path={}",
|
||||
preset.game_id,
|
||||
path.display()
|
||||
);
|
||||
let payload = serde_json::to_string_pretty(preset)
|
||||
.map_err(|err| format!("failed to serialize preset: {err}"))?;
|
||||
write_json_atomic(&path, "preset", &payload)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_preset(
|
||||
workspace_dir: &Path,
|
||||
game_id: &str,
|
||||
) -> Result<Option<GameplayReviewPreset>, String> {
|
||||
let path = preset_path(workspace_dir, game_id);
|
||||
trace!(
|
||||
"[gameplay_review][store] load_preset game_id={} path={}",
|
||||
game_id,
|
||||
path.display()
|
||||
);
|
||||
if !path.exists() {
|
||||
debug!(
|
||||
"[gameplay_review][store] load_preset missing game_id={} path={}",
|
||||
game_id,
|
||||
path.display()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read_to_string(&path).map_err(|err| format!("failed to read preset: {err}"))?;
|
||||
let preset = serde_json::from_str(&raw)
|
||||
.map_err(|err| format!("failed to parse preset {}: {err}", path.display()))?;
|
||||
debug!(
|
||||
"[gameplay_review][store] load_preset loaded game_id={} path={}",
|
||||
game_id,
|
||||
path.display()
|
||||
);
|
||||
Ok(Some(preset))
|
||||
}
|
||||
|
||||
pub fn list_presets(workspace_dir: &Path) -> Result<Vec<GameplayReviewPreset>, String> {
|
||||
let dir = presets_dir(workspace_dir);
|
||||
trace!(
|
||||
"[gameplay_review][store] list_presets dir={}",
|
||||
dir.display()
|
||||
);
|
||||
if !dir.exists() {
|
||||
debug!(
|
||||
"[gameplay_review][store] list_presets empty dir={}",
|
||||
dir.display()
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut presets = Vec::new();
|
||||
for entry in fs::read_dir(&dir).map_err(|err| format!("failed to list presets: {err}"))? {
|
||||
let entry = entry.map_err(|err| format!("failed to read preset entry: {err}"))?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let raw = fs::read_to_string(&path)
|
||||
.map_err(|err| format!("failed to read preset {}: {err}", path.display()))?;
|
||||
match serde_json::from_str::<GameplayReviewPreset>(&raw) {
|
||||
Ok(preset) => presets.push(preset),
|
||||
Err(err) => warn!(
|
||||
"[gameplay_review][store] list_presets skipped invalid preset path={} error={}",
|
||||
path.display(),
|
||||
err
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
presets.sort_by(|left, right| left.display_name.cmp(&right.display_name));
|
||||
debug!(
|
||||
"[gameplay_review][store] list_presets complete dir={} count={}",
|
||||
dir.display(),
|
||||
presets.len()
|
||||
);
|
||||
Ok(presets)
|
||||
}
|
||||
|
||||
pub fn workspace_dir_from_config(config: &Config) -> PathBuf {
|
||||
config.workspace_dir.clone()
|
||||
}
|
||||
|
||||
pub fn preset_from_input(input: GameplayPresetInput) -> GameplayReviewPreset {
|
||||
GameplayReviewPreset {
|
||||
game_id: input.game_id,
|
||||
display_name: input.display_name,
|
||||
coaching_focus: input.coaching_focus,
|
||||
audio_feedback: input.audio_feedback,
|
||||
spoiler_mode: input.spoiler_mode,
|
||||
notes: input.notes,
|
||||
updated_at_ms: chrono::Utc::now().timestamp_millis(),
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SpoilerMode {
|
||||
Off,
|
||||
Light,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl Default for SpoilerMode {
|
||||
fn default() -> Self {
|
||||
Self::Light
|
||||
}
|
||||
}
|
||||
|
||||
impl SpoilerMode {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "off",
|
||||
Self::Light => "light",
|
||||
Self::Full => "full",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HighlightKind {
|
||||
Highlight,
|
||||
Mistake,
|
||||
Coaching,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayFrameInput {
|
||||
pub file_name: String,
|
||||
pub image_ref: String,
|
||||
#[serde(default)]
|
||||
pub captured_at_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayPresetInput {
|
||||
pub game_id: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub coaching_focus: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub audio_feedback: bool,
|
||||
#[serde(default)]
|
||||
pub spoiler_mode: SpoilerMode,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayReviewSessionInput {
|
||||
pub game_id: String,
|
||||
pub session_title: String,
|
||||
#[serde(default)]
|
||||
pub source_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub spoiler_mode: Option<SpoilerMode>,
|
||||
#[serde(default)]
|
||||
pub preset_id: Option<String>,
|
||||
pub frames: Vec<GameplayFrameInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayReviewQuestionInput {
|
||||
pub session_id: String,
|
||||
pub question: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayReviewPreset {
|
||||
pub game_id: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub coaching_focus: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub audio_feedback: bool,
|
||||
#[serde(default)]
|
||||
pub spoiler_mode: SpoilerMode,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
pub updated_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayHighlight {
|
||||
pub id: String,
|
||||
pub frame_index: usize,
|
||||
#[serde(default)]
|
||||
pub captured_at_ms: Option<i64>,
|
||||
pub title: String,
|
||||
pub rationale: String,
|
||||
pub confidence: f32,
|
||||
pub kind: HighlightKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayClipCandidate {
|
||||
pub id: String,
|
||||
pub frame_index: usize,
|
||||
pub start_label: String,
|
||||
pub end_label: String,
|
||||
pub rationale: String,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayPlatformDraft {
|
||||
pub platform: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayReviewAnalysis {
|
||||
pub recap: String,
|
||||
pub highlights: Vec<GameplayHighlight>,
|
||||
pub clip_candidates: Vec<GameplayClipCandidate>,
|
||||
pub draft_metadata: Vec<GameplayPlatformDraft>,
|
||||
pub follow_up_questions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub spoiler_note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayReviewSession {
|
||||
pub session_id: String,
|
||||
pub game_id: String,
|
||||
pub session_title: String,
|
||||
#[serde(default)]
|
||||
pub source_label: Option<String>,
|
||||
pub spoiler_mode: SpoilerMode,
|
||||
#[serde(default)]
|
||||
pub preset_id: Option<String>,
|
||||
pub imported_at_ms: i64,
|
||||
#[serde(default)]
|
||||
pub analyzed_at_ms: Option<i64>,
|
||||
pub frames: Vec<GameplayFrameInput>,
|
||||
#[serde(default)]
|
||||
pub analysis: Option<GameplayReviewAnalysis>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayReviewAnalysisInput {
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub max_highlights: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayReviewClipInput {
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
#[serde(default)]
|
||||
pub highlight_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameplayReviewQuestionResult {
|
||||
pub answer: String,
|
||||
pub matched_highlights: Vec<GameplayHighlight>,
|
||||
pub suggested_follow_up: Vec<String>,
|
||||
}
|
||||
@@ -42,7 +42,6 @@ pub mod devices;
|
||||
pub mod doctor;
|
||||
pub mod embeddings;
|
||||
pub mod encryption;
|
||||
pub mod gameplay_review;
|
||||
pub mod health;
|
||||
pub mod heartbeat;
|
||||
pub mod http_host;
|
||||
|
||||
Reference in New Issue
Block a user