From c0e25766e43f7d56976f63fcfdd108642d96f7d7 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 3 Jun 2026 17:04:54 -0700 Subject: [PATCH 01/79] wip(automations): port automations natively in to desktop part 1 --- app/app-services.ts | 12 + .../editor/elements/SceneSelector.tsx | 7 + app/components-react/index.ts | 2 + .../AutomationEditor.tsx | 446 ++++++++++++++++++ .../EditAutomations.m.less | 94 ++++ .../EditAutomations.tsx | 187 ++++++++ app/components/shared/ReactComponentList.tsx | 8 + app/i18n/en-US/stream-avatar-automations.json | 49 ++ app/i18n/fallback.ts | 1 + app/services/hosts.ts | 10 + .../stream-avatar/agent-socket-service.ts | 162 +++++++ .../automations-engine-service.ts | 312 ++++++++++++ .../stream-avatar/automations-service.ts | 111 +++++ app/services/stream-avatar/engine/actions.ts | 300 ++++++++++++ .../stream-avatar/engine/automations.ts | 18 + .../engine/conditions/apexlegends.ts | 114 +++++ .../engine/conditions/arcraiders.ts | 100 ++++ .../engine/conditions/battlefield6.ts | 72 +++ .../engine/conditions/blackops6.ts | 54 +++ .../engine/conditions/counterstrike2.ts | 146 ++++++ .../engine/conditions/deadbydaylight.ts | 80 ++++ .../engine/conditions/deadlock.ts | 70 +++ .../stream-avatar/engine/conditions/dota2.ts | 88 ++++ .../engine/conditions/easportsfc26.ts | 70 +++ .../engine/conditions/enshrouded.ts | 56 +++ .../stream-avatar/engine/conditions/f125.ts | 62 +++ .../engine/conditions/fortnite.ts | 184 ++++++++ .../engine/conditions/forzahorizon6.ts | 80 ++++ .../stream-avatar/engine/conditions/index.ts | 187 ++++++++ .../engine/conditions/leagueoflegends.ts | 106 +++++ .../engine/conditions/marathon.ts | 88 ++++ .../engine/conditions/marvelrivals.ts | 64 +++ .../engine/conditions/minecraft.ts | 100 ++++ .../engine/conditions/nba2k26.ts | 52 ++ .../engine/conditions/overwatch2.ts | 72 +++ .../stream-avatar/engine/conditions/pubg.ts | 150 ++++++ .../engine/conditions/rainbowsixsiege.ts | 80 ++++ .../engine/conditions/rocketleague.ts | 72 +++ .../engine/conditions/valorant.ts | 102 ++++ .../engine/conditions/warthunder.ts | 28 ++ .../engine/conditions/warzone.ts | 155 ++++++ .../stream-avatar/engine/game-state.ts | 30 ++ .../engine/instructions/apexlegends.ts | 18 + .../engine/instructions/arcraiders.ts | 18 + .../engine/instructions/battlefield6.ts | 13 + .../engine/instructions/blackops6.ts | 12 + .../engine/instructions/counterstrike2.ts | 23 + .../engine/instructions/deadbydaylight.ts | 14 + .../engine/instructions/deadlock.ts | 13 + .../engine/instructions/dota2.ts | 15 + .../engine/instructions/easportsfc26.ts | 13 + .../engine/instructions/enshrouded.ts | 11 + .../stream-avatar/engine/instructions/f125.ts | 12 + .../engine/instructions/fortnite.ts | 30 ++ .../engine/instructions/forzahorizon6.ts | 14 + .../engine/instructions/index.ts | 56 +++ .../engine/instructions/leagueoflegends.ts | 17 + .../engine/instructions/marathon.ts | 15 + .../engine/instructions/marvelrivals.ts | 12 + .../engine/instructions/minecraft.ts | 27 ++ .../engine/instructions/nba2k26.ts | 11 + .../engine/instructions/overwatch2.ts | 13 + .../stream-avatar/engine/instructions/pubg.ts | 21 + .../engine/instructions/rainbowsixsiege.ts | 14 + .../engine/instructions/rocketleague.ts | 17 + .../engine/instructions/valorant.ts | 15 + .../engine/instructions/warthunder.ts | 8 + .../engine/instructions/warzone.ts | 22 + .../stream-avatar/engine/properties.ts | 71 +++ .../stream-avatar/engine/validation.ts | 159 +++++++ .../stream-avatar-api-service.ts | 70 +++ app/services/utils.ts | 4 + app/services/windows.ts | 2 + 73 files changed, 4941 insertions(+) create mode 100644 app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx create mode 100644 app/components-react/windows/stream-avatar-automations/EditAutomations.m.less create mode 100644 app/components-react/windows/stream-avatar-automations/EditAutomations.tsx create mode 100644 app/i18n/en-US/stream-avatar-automations.json create mode 100644 app/services/stream-avatar/agent-socket-service.ts create mode 100644 app/services/stream-avatar/automations-engine-service.ts create mode 100644 app/services/stream-avatar/automations-service.ts create mode 100644 app/services/stream-avatar/engine/actions.ts create mode 100644 app/services/stream-avatar/engine/automations.ts create mode 100644 app/services/stream-avatar/engine/conditions/apexlegends.ts create mode 100644 app/services/stream-avatar/engine/conditions/arcraiders.ts create mode 100644 app/services/stream-avatar/engine/conditions/battlefield6.ts create mode 100644 app/services/stream-avatar/engine/conditions/blackops6.ts create mode 100644 app/services/stream-avatar/engine/conditions/counterstrike2.ts create mode 100644 app/services/stream-avatar/engine/conditions/deadbydaylight.ts create mode 100644 app/services/stream-avatar/engine/conditions/deadlock.ts create mode 100644 app/services/stream-avatar/engine/conditions/dota2.ts create mode 100644 app/services/stream-avatar/engine/conditions/easportsfc26.ts create mode 100644 app/services/stream-avatar/engine/conditions/enshrouded.ts create mode 100644 app/services/stream-avatar/engine/conditions/f125.ts create mode 100644 app/services/stream-avatar/engine/conditions/fortnite.ts create mode 100644 app/services/stream-avatar/engine/conditions/forzahorizon6.ts create mode 100644 app/services/stream-avatar/engine/conditions/index.ts create mode 100644 app/services/stream-avatar/engine/conditions/leagueoflegends.ts create mode 100644 app/services/stream-avatar/engine/conditions/marathon.ts create mode 100644 app/services/stream-avatar/engine/conditions/marvelrivals.ts create mode 100644 app/services/stream-avatar/engine/conditions/minecraft.ts create mode 100644 app/services/stream-avatar/engine/conditions/nba2k26.ts create mode 100644 app/services/stream-avatar/engine/conditions/overwatch2.ts create mode 100644 app/services/stream-avatar/engine/conditions/pubg.ts create mode 100644 app/services/stream-avatar/engine/conditions/rainbowsixsiege.ts create mode 100644 app/services/stream-avatar/engine/conditions/rocketleague.ts create mode 100644 app/services/stream-avatar/engine/conditions/valorant.ts create mode 100644 app/services/stream-avatar/engine/conditions/warthunder.ts create mode 100644 app/services/stream-avatar/engine/conditions/warzone.ts create mode 100644 app/services/stream-avatar/engine/game-state.ts create mode 100644 app/services/stream-avatar/engine/instructions/apexlegends.ts create mode 100644 app/services/stream-avatar/engine/instructions/arcraiders.ts create mode 100644 app/services/stream-avatar/engine/instructions/battlefield6.ts create mode 100644 app/services/stream-avatar/engine/instructions/blackops6.ts create mode 100644 app/services/stream-avatar/engine/instructions/counterstrike2.ts create mode 100644 app/services/stream-avatar/engine/instructions/deadbydaylight.ts create mode 100644 app/services/stream-avatar/engine/instructions/deadlock.ts create mode 100644 app/services/stream-avatar/engine/instructions/dota2.ts create mode 100644 app/services/stream-avatar/engine/instructions/easportsfc26.ts create mode 100644 app/services/stream-avatar/engine/instructions/enshrouded.ts create mode 100644 app/services/stream-avatar/engine/instructions/f125.ts create mode 100644 app/services/stream-avatar/engine/instructions/fortnite.ts create mode 100644 app/services/stream-avatar/engine/instructions/forzahorizon6.ts create mode 100644 app/services/stream-avatar/engine/instructions/index.ts create mode 100644 app/services/stream-avatar/engine/instructions/leagueoflegends.ts create mode 100644 app/services/stream-avatar/engine/instructions/marathon.ts create mode 100644 app/services/stream-avatar/engine/instructions/marvelrivals.ts create mode 100644 app/services/stream-avatar/engine/instructions/minecraft.ts create mode 100644 app/services/stream-avatar/engine/instructions/nba2k26.ts create mode 100644 app/services/stream-avatar/engine/instructions/overwatch2.ts create mode 100644 app/services/stream-avatar/engine/instructions/pubg.ts create mode 100644 app/services/stream-avatar/engine/instructions/rainbowsixsiege.ts create mode 100644 app/services/stream-avatar/engine/instructions/rocketleague.ts create mode 100644 app/services/stream-avatar/engine/instructions/valorant.ts create mode 100644 app/services/stream-avatar/engine/instructions/warthunder.ts create mode 100644 app/services/stream-avatar/engine/instructions/warzone.ts create mode 100644 app/services/stream-avatar/engine/properties.ts create mode 100644 app/services/stream-avatar/engine/validation.ts create mode 100644 app/services/stream-avatar/stream-avatar-api-service.ts diff --git a/app/app-services.ts b/app/app-services.ts index 732d5d092fdd..619d602e8aa2 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -64,6 +64,10 @@ export { SettingsManagerService } from 'services/settings-manager'; export { MarkersService } from 'services/markers'; export { RealmService } from 'services/realm'; export { StreamAvatarService } from 'services/stream-avatar/stream-avatar-service'; +export { StreamAvatarApiService } from 'services/stream-avatar/stream-avatar-api-service'; +export { AgentSocketService } from 'services/stream-avatar/agent-socket-service'; +export { AutomationsService } from 'services/stream-avatar/automations-service'; +export { AutomationsEngineService } from 'services/stream-avatar/automations-engine-service'; export { OnboardingV2Service } from 'services/onboarding/onboarding-v2'; // ONLINE SERVICES @@ -212,6 +216,10 @@ import { SignalsService } from 'services/signals-manager'; import { TroubleshooterService } from 'services/troubleshooter'; import { OnboardingV2Service } from 'services/onboarding/onboarding-v2'; import { VirtualWebcamService } from 'services/virtual-webcam'; +import { StreamAvatarApiService } from 'services/stream-avatar/stream-avatar-api-service'; +import { AgentSocketService } from 'services/stream-avatar/agent-socket-service'; +import { AutomationsService } from 'services/stream-avatar/automations-service'; +import { AutomationsEngineService } from 'services/stream-avatar/automations-engine-service'; export const AppServices = { AppService, @@ -301,4 +309,8 @@ export const AppServices = { ReactiveDataService, TroubleshooterService, OnboardingV2Service, + StreamAvatarApiService, + AgentSocketService, + AutomationsService, + AutomationsEngineService, }; diff --git a/app/components-react/editor/elements/SceneSelector.tsx b/app/components-react/editor/elements/SceneSelector.tsx index f35d0f68cfd4..ab36ebd683dd 100644 --- a/app/components-react/editor/elements/SceneSelector.tsx +++ b/app/components-react/editor/elements/SceneSelector.tsx @@ -25,6 +25,7 @@ function SceneSelector() { SourceFiltersService, ProjectorService, EditorCommandsService, + AutomationsService, } = Services; const { treeSort } = useTree(true); @@ -177,6 +178,12 @@ function SceneSelector() { + + AutomationsService.actions.showAutomations()} + /> + ({ + type: type as ActionType, + label: def.label, +})); + +const GAME_OPTIONS = Object.entries(GAME_NAMES).map(([id, name]) => ({ id, name })); + +function getConditionOptions(gameId: string) { + return Object.entries(Conditions) + .filter(([, def]) => def.group === gameId && !def.disabled) + .map(([key, def]) => ({ type: key as ConditionType, label: def.label })); +} + +const inputStyle: CSSProperties = { + background: 'var(--solid-input)', + color: 'var(--title)', + border: '1px solid var(--border)', + borderRadius: '4px', + padding: '4px 6px', + boxSizing: 'border-box', +}; + +const labelStyle: CSSProperties = { + display: 'block', + marginBottom: '4px', + fontWeight: 600, + color: 'var(--title)', +}; + +interface ActionEditorProps { + action: ExportedAction; + index: number; + scenes: { id: string; name: string }[]; + sources: { id: string; name: string }[]; + errors?: Record; + onChange: (index: number, action: ExportedAction) => void; + onRemove: (index: number) => void; +} + +function ActionEditor({ + action, + index, + scenes, + sources, + errors, + onChange, + onRemove, +}: ActionEditorProps) { + function setType(type: ActionType) { + onChange(index, withActionDefaults({ type })); + } + + function setProp(key: string, value: unknown) { + onChange(index, { + ...action, + props: { ...(action.props as ExportedActionProps), [key]: value }, + }); + } + + const props = (action.props || {}) as ExportedActionProps; + + const sceneName = props.scene?.name ?? ''; + const sceneMissing = !!sceneName && !scenes.some(s => s.name === sceneName); + const sourceName = props.source?.name ?? ''; + const sourceMissing = !!sourceName && !sources.some(s => s.name === sourceName); + + return ( +
+
+ + +
+ + {action.type === 'common.switch_to_scene' && ( + <> + + {errors?.scene &&

{errors.scene}

} + + )} + + {(action.type === 'common.show_source' || action.type === 'common.hide_source') && ( +
+ + {errors?.source &&

{errors.source}

} + {action.type === 'common.show_source' && ( + + )} + {action.type === 'common.hide_source' && ( + + )} +
+ )} + + {action.type === 'common.wait_for_ms' && ( +
+ + setProp('duration', Number(e.target.value))} + style={{ width: '100%' }} + /> +
+ )} + + {action.type === 'co-host.comment' && ( +

+ {$t('The co-host will automatically comment based on the active game condition.')} +

+ )} + + {action.type === 'co-host.instruction' && ( + <> + setProp('instruction', e.target.value)} + placeholder={$t('Instruction')} + maxLength={MAX_INSTRUCTION_LENGTH} + style={{ ...inputStyle, width: '100%', ...fieldBorder(!!errors?.instruction) }} + /> + {errors?.instruction &&

{errors.instruction}

} + + )} +
+ ); +} + +interface Props { + initial?: TAutomationExport; + onClose: () => void; +} + +export default function AutomationEditor({ initial, onClose }: Props) { + const { AutomationsService, ScenesService, SourcesService } = Services; + + const { scenes, sources } = useVuex(() => ({ + scenes: ScenesService.views.scenes.map(s => ({ id: s.id, name: s.name })), + sources: SourcesService.views.sources.map(s => ({ id: s.sourceId, name: s.name })), + })); + + const [description, setDescription] = useState(initial?.description ?? ''); + const [selectedGame, setSelectedGame] = useState(() => { + if (initial?.conditions?.[0]) { + return (initial.conditions[0].type as string).split('.')[0]; + } + return GAME_OPTIONS[0].id; + }); + const [conditionType, setConditionType] = useState(() => { + return (initial?.conditions?.[0]?.type as ConditionType) ?? ''; + }); + const [actions, setActions] = useState( + (initial?.actions as ExportedAction[])?.filter(a => a?.type).map(withActionDefaults) ?? [ + withActionDefaults({ type: 'common.save_replay' }), + ], + ); + // Enable/disable is managed from the list, not here; new automations default + // to enabled and edits preserve the existing value. + const enabled = initial?.enabled ?? true; + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + // Required-field errors stay hidden on a fresh form until the first save + // attempt; existing automations show them immediately so a deleted scene/ + // source surfaces the moment the editor opens. + const [attempted, setAttempted] = useState(!!initial); + + const conditionOptions = getConditionOptions(selectedGame); + + const draft: TAutomationExport = { + description, + conditions: conditionType ? [{ type: conditionType }] : [], + actions, + enabled, + }; + const issues = validateAutomation(draft, { scenes, sources }); + + const descriptionError = attempted + ? issues.find(i => i.scope === 'description')?.message + : undefined; + const conditionError = attempted + ? issues.find(i => i.scope === 'conditions')?.message + : undefined; + // "Add at least one action" has no actionIndex; show it once a save is tried. + const actionsError = attempted + ? issues.find(i => i.scope === 'action' && i.actionIndex === undefined)?.message + : undefined; + // Per-action field errors render live so unavailable selections are obvious. + const actionErrors: Record> = {}; + issues.forEach(i => { + if (i.scope === 'action' && i.actionIndex !== undefined && i.field) { + actionErrors[i.actionIndex] = actionErrors[i.actionIndex] ?? {}; + actionErrors[i.actionIndex][i.field] = i.message; + } + }); + + useEffect(() => { + // Reset condition selection when game changes + if (conditionOptions.length > 0) { + const current = conditionOptions.find(o => o.type === conditionType); + if (!current) setConditionType(conditionOptions[0].type); + } else { + setConditionType(''); + } + }, [selectedGame]); + + function handleActionChange(index: number, action: ExportedAction) { + setActions(prev => prev.map((a, i) => (i === index ? action : a))); + } + + function handleActionRemove(index: number) { + setActions(prev => prev.filter((_, i) => i !== index)); + } + + function handleAddAction() { + setActions(prev => [...prev, withActionDefaults({ type: 'common.save_replay' })]); + } + + async function handleSave() { + setAttempted(true); + if (issues.length > 0) { + setError($t('Please fix the highlighted fields before saving.')); + return; + } + + setError(''); + setSaving(true); + try { + const payload: Omit = { + description: description.trim(), + conditions: conditionType ? [{ type: conditionType }] : [], + actions, + enabled, + }; + + if (initial?.id) { + await AutomationsService.actions.update(initial.id, payload); + } else { + await AutomationsService.actions.create(payload); + } + onClose(); + } catch (e: any) { + setError(e?.message ?? $t('Failed to save automation.')); + } finally { + setSaving(false); + } + } + + const footer = ( + <> + + + + ); + + return ( + +

+ {initial ? $t('Edit Automation') : $t('New Automation')} +

+ +
+ {/* Description */} +
+ + setDescription(e.target.value)} + maxLength={100} + placeholder={$t('e.g. Victory Royale reaction')} + style={{ + ...inputStyle, + width: '100%', + padding: '6px 8px', + ...fieldBorder(!!descriptionError), + }} + /> + {descriptionError &&

{descriptionError}

} +
+ + {/* Condition */} +
+ +
+ + +
+ {conditionError &&

{conditionError}

} +
+ + {/* Actions */} +
+ + {actions.map((action, i) => ( + + ))} + {actionsError &&

{actionsError}

} + +
+ + {error &&

{error}

} +
+
+ ); +} diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less b/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less new file mode 100644 index 000000000000..60845acc8467 --- /dev/null +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less @@ -0,0 +1,94 @@ +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th { + text-align: left; + text-transform: uppercase; + font-size: 11px; + font-weight: 400; + letter-spacing: 0.4px; + color: var(--paragraph); + padding: 8px 12px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.table td { + padding: 12px; + color: var(--title); + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.table tbody tr:hover { + background: var(--section); +} + +.descCell { + font-weight: 600; +} + +.mutedCell { + color: var(--paragraph); +} + +.actionsCol { + width: 1%; +} + +.rowActions { + display: flex; + align-items: center; + gap: 14px; + justify-content: flex-end; +} + +.rowActions i { + cursor: pointer; + color: var(--icon-active); + font-size: 14px; +} + +.rowActions i:hover { + color: var(--title); +} + +.errorIcon, +.rowActions i.errorIcon, +.rowActions i.errorIcon:hover { + color: var(--red); + cursor: default; +} + +.disabledIcon, +.rowActions i.disabledIcon, +.rowActions i.disabledIcon:hover { + opacity: 0.4; + pointer-events: none; + color: var(--icon-active); +} + +.badge { + display: inline-block; + background: var(--section); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 8px; + font-size: 11px; + margin-right: 6px; + white-space: nowrap; + color: var(--title); +} + +.message { + padding: 24px; + color: var(--paragraph); + text-align: center; +} + +.empty { + margin-top: 48px; +} diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx new file mode 100644 index 000000000000..00a43d53b449 --- /dev/null +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useState } from 'react'; +import { Switch, Tooltip, Empty, Spin } from 'antd'; +import { ModalLayout } from 'components-react/shared/ModalLayout'; +import { useVuex } from 'components-react/hooks'; +import { Services } from 'components-react/service-provider'; +import { $t } from 'services/i18n'; +import { Conditions, GAME_NAMES } from 'services/stream-avatar/engine/conditions'; +import { ActionRegistry } from 'services/stream-avatar/engine/actions'; +import { validateAutomation } from 'services/stream-avatar/engine/validation'; +import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; +import AutomationEditor from './AutomationEditor'; +import styles from './EditAutomations.m.less'; + +function conditionLabel(condition: { type: string } | null) { + if (!condition?.type) return $t('(unknown)'); + const def = Conditions[condition.type as keyof typeof Conditions]; + return def ? def.label : condition.type; +} + +function conditionGame(condition: { type: string } | null) { + if (!condition?.type) return ''; + const def = Conditions[condition.type as keyof typeof Conditions]; + if (!def) return ''; + return GAME_NAMES[def.group] ?? def.group; +} + +function summarizeActions(actions: TAutomationExport['actions']) { + return actions + .filter(a => a?.type) + .map(a => { + const def = ActionRegistry[a.type as keyof typeof ActionRegistry]; + return def ? def.label : a.type; + }) + .join(', '); +} + +export default function EditAutomations() { + const { AutomationsService, AutomationsEngineService, ScenesService, SourcesService } = Services; + const { automations, loading, scenes, sources } = useVuex(() => ({ + automations: AutomationsService.state.automations, + loading: AutomationsService.state.loading, + scenes: ScenesService.views.scenes.map(s => ({ id: s.id, name: s.name })), + sources: SourcesService.views.sources.map(s => ({ id: s.sourceId, name: s.name })), + })); + + const [editingAutomation, setEditingAutomation] = useState(null); + const [creating, setCreating] = useState(false); + const [simulatingId, setSimulatingId] = useState(null); + + useEffect(() => { + AutomationsService.actions.fetchAll(); + }, []); + + async function simulate(automation: TAutomationExport) { + if (!automation.id || simulatingId !== null) return; + setSimulatingId(automation.id); + try { + // `.return` so the promise resolves only after the worker finishes the + // simulation (including its revert delay), keeping the spinner accurate. + await AutomationsEngineService.actions.return.simulateAutomation(automation.id); + } finally { + setSimulatingId(null); + } + } + + function toggleEnabled(automation: TAutomationExport) { + if (!automation.id) return; + AutomationsService.actions.update(automation.id, { + ...automation, + enabled: !automation.enabled, + }); + } + + function remove(automation: TAutomationExport) { + if (!automation.id) return; + if (!window.confirm($t('Delete this automation?'))) return; + AutomationsService.actions.remove(automation.id); + } + + function edit(automation: TAutomationExport) { + setEditingAutomation(automation); + setCreating(false); + } + + function create() { + setEditingAutomation(null); + setCreating(true); + } + + function closeEditor() { + setEditingAutomation(null); + setCreating(false); + } + + if (creating || editingAutomation) { + return ; + } + + return ( + + {loading &&
{$t('Loading...')}
} + + {!loading && automations.length === 0 && ( + + )} + + {!loading && automations.length > 0 && ( + + + + + + + + + + + {automations.map(automation => { + const issues = validateAutomation(automation, { scenes, sources }); + return ( + + + + + + + + ); + })} + +
{$t('Description')}{$t('When')}{$t('Do')}{$t('Game')} +
+ {automation.description || $t('(no description)')} + {automation.conditions.map(c => conditionLabel(c)).join(', ')}{summarizeActions(automation.actions)} + {automation.conditions.map((c, i) => { + const game = conditionGame(c); + return game ? ( + + {game} + + ) : null; + })} + +
+ {issues.length > 0 && ( + + {issues.map((issue, i) => ( +
{issue.message}
+ ))} +
+ } + > + + + )} + + toggleEnabled(automation)} + /> + + + {simulatingId === automation.id ? ( + + ) : ( + simulate(automation)} + /> + )} + + + edit(automation)} /> + + + remove(automation)} /> + + +
+ )} +
+ ); +} diff --git a/app/components/shared/ReactComponentList.tsx b/app/components/shared/ReactComponentList.tsx index fcc034b83c9a..51edb524ff93 100644 --- a/app/components/shared/ReactComponentList.tsx +++ b/app/components/shared/ReactComponentList.tsx @@ -73,6 +73,14 @@ export class EditStreamWindow extends ReactComponent {} }) export class EditTransform extends ReactComponent {} +@Component({ + props: { + name: { default: 'EditAutomations' }, + wrapperStyles: { default: () => ({ height: '100%' }) }, + }, +}) +export class EditAutomations extends ReactComponent {} + @Component({ props: { name: { default: 'GoLiveWindow' }, diff --git a/app/i18n/en-US/stream-avatar-automations.json b/app/i18n/en-US/stream-avatar-automations.json new file mode 100644 index 000000000000..9b7829a948e8 --- /dev/null +++ b/app/i18n/en-US/stream-avatar-automations.json @@ -0,0 +1,49 @@ +{ + "Edit Automations.": "Edit Automations.", + "New Automation": "New Automation", + "Edit Automation": "Edit Automation", + "Create Automation": "Create Automation", + "Delete this automation?": "Delete this automation?", + "You don't have any automations yet.": "You don't have any automations yet.", + "Loading...": "Loading...", + "Test automation": "Test automation", + "Enabled": "Enabled", + "Disabled": "Disabled", + "(unknown)": "(unknown)", + "(no description)": "(no description)", + "Description": "Description", + "When": "When", + "Do": "Do", + "Game": "Game", + "Edit": "Edit", + "Delete": "Delete", + "Back": "Back", + "Saving...": "Saving...", + "Save Changes": "Save Changes", + "When (Condition)": "When (Condition)", + "Do (Actions)": "Do (Actions)", + "No conditions available": "No conditions available", + "+ Add Action": "+ Add Action", + "e.g. Victory Royale reaction": "e.g. Victory Royale reaction", + "— select scene —": "— select scene —", + "— select source —": "— select source —", + "unavailable": "unavailable", + "Duration": "Duration", + "Instruction": "Instruction", + "Hide if condition is false": "Hide if condition is false", + "Show if condition is false": "Show if condition is false", + "The co-host will automatically comment based on the active game condition.": "The co-host will automatically comment based on the active game condition.", + "Please fix the highlighted fields before saving.": "Please fix the highlighted fields before saving.", + "Failed to save automation.": "Failed to save automation.", + "Add a description.": "Add a description.", + "Description must be %{max} characters or fewer.": "Description must be %{max} characters or fewer.", + "Select a condition.": "Select a condition.", + "This automation uses an unknown condition.": "This automation uses an unknown condition.", + "Add at least one action.": "Add at least one action.", + "Select a scene to switch to.": "Select a scene to switch to.", + "Scene \"%{name}\" no longer exists.": "Scene \"%{name}\" no longer exists.", + "Select a source.": "Select a source.", + "Source \"%{name}\" is unavailable.": "Source \"%{name}\" is unavailable.", + "Enter an instruction for the co-host.": "Enter an instruction for the co-host.", + "Instruction must be %{max} characters or fewer.": "Instruction must be %{max} characters or fewer." +} diff --git a/app/i18n/fallback.ts b/app/i18n/fallback.ts index b8de49b776f6..0834469de6a6 100644 --- a/app/i18n/fallback.ts +++ b/app/i18n/fallback.ts @@ -72,6 +72,7 @@ const fallbackDictionary = { ...require('./en-US/developer.json'), ...require('./en-US/dual-output.json'), ...require('./en-US/patreon.json'), + ...require('./en-US/stream-avatar-automations.json'), }; export default fallbackDictionary; diff --git a/app/services/hosts.ts b/app/services/hosts.ts index 198c653451e5..03d1e3ec3276 100644 --- a/app/services/hosts.ts +++ b/app/services/hosts.ts @@ -47,6 +47,16 @@ export class HostsService extends Service { get analitycs() { return 'r2d2.streamlabs.com'; } + + get streamAvatarApi() { + if (Util.shouldUseAvatarLocalHost()) { + return 'localhost:3000'; + } else if (Util.shouldUseBeta()) { + return 'ai-agent.streamlabs.com'; + } + + return 'isa.streamlabs.com'; + } } export class UrlService extends Service { diff --git a/app/services/stream-avatar/agent-socket-service.ts b/app/services/stream-avatar/agent-socket-service.ts new file mode 100644 index 000000000000..955e4dfdeec2 --- /dev/null +++ b/app/services/stream-avatar/agent-socket-service.ts @@ -0,0 +1,162 @@ +import { InitAfter, Inject, Service } from 'services/core'; +import { StreamAvatarApiService } from './stream-avatar-api-service'; +import { HostsService } from 'services/hosts'; +import { UserService } from 'services/user'; +import { importSocketIOClient } from 'util/slow-imports'; +import Utils from 'services/utils'; + +interface SocketAck { + ok: boolean; + data?: T; + error?: string; +} + +@InitAfter('UserService') +export class AgentSocketService extends Service { + @Inject() private streamAvatarApiService: StreamAvatarApiService; + @Inject() private hostsService: HostsService; + @Inject() private userService: UserService; + + private socket: SocketIOClient.Socket | null = null; + private readyPromise: Promise = Promise.resolve(); + private readyResolve: (() => void) | null = null; + + init() { + console.log('[AgentSocket] init() called. isWorkerWindow:', Utils.isWorkerWindow()); + if (!Utils.isWorkerWindow()) return; + this.resetReady(); + console.log('[AgentSocket] init() isLoggedIn:', this.userService.isLoggedIn); + if (this.userService.isLoggedIn) { + this.connect(); + } + this.userService.userLogin.subscribe(() => { + console.log('[AgentSocket] userLogin fired, reconnecting'); + this.resetReady(); + this.connect(); + }); + } + + private resetReady() { + this.readyPromise = new Promise(resolve => { + this.readyResolve = resolve; + }); + } + + private get socketUrl(): string { + const protocol = Utils.shouldUseAvatarLocalHost() ? 'http://' : 'https://'; + return `${protocol}${this.hostsService.streamAvatarApi}`; + } + + private async connect() { + try { + console.log('[AgentSocket] connect() start. URL:', this.socketUrl); + const io = (await importSocketIOClient()).default; + console.log('[AgentSocket] socket.io-client imported, requesting token...'); + const jwt = await this.streamAvatarApiService.getToken(); + console.log('[AgentSocket] token obtained, length:', jwt?.length); + + if (this.socket) { + this.socket.disconnect(); + } + + // NOTE: desktop bundles socket.io-client v2, which has no `auth` handshake + // field (added in v3). The token must be sent via `query`; the backend + // reads `socket.handshake.query.token` as a fallback. Requires the server + // to run with `allowEIO3: true`. + this.socket = io(this.socketUrl, { + autoConnect: false, + reconnection: true, + reconnectionDelay: 1000, + timeout: 20000, + transports: ['websocket'], + query: { token: jwt }, + } as any); + + this.socket.on('connect', () => { + console.log('[AgentSocket] socket "connect" event, id:', this.socket?.id); + }); + + this.socket.on('authenticated', (payload: any) => { + console.log('[AgentSocket] "authenticated" event received', payload); + this.readyResolve?.(); + }); + + this.socket.on('authError', (err: any) => { + console.error('[AgentSocket] "authError" event:', err); + }); + + this.socket.on('disconnect', (reason: any) => { + console.warn('[AgentSocket] "disconnect" event:', reason); + this.resetReady(); + }); + + this.socket.on('connect_error', async (err: any) => { + console.error('[AgentSocket] "connect_error" event:', err?.message, err); + if (err?.message?.includes('unauthorized') || err?.message?.includes('auth')) { + try { + const freshJwt = await this.streamAvatarApiService.getToken(true); + if (this.socket) { + // v2 client: update the query token (no `auth` field support) + (this.socket as any).io.opts.query = { token: freshJwt }; + this.socket.connect(); + } + } catch (e) { + console.error('[AgentSocket] token refresh on connect_error failed:', e); + } + } + }); + + console.log('[AgentSocket] calling socket.connect()...'); + this.socket.connect(); + } catch (e) { + console.error('[AgentSocket] connect() threw:', e); + } + } + + private async call(event: string, ...args: any[]): Promise { + console.log(`[AgentSocket] call("${event}") awaiting ready... socket connected:`, this.socket?.connected); + await this.readyPromise; + console.log(`[AgentSocket] call("${event}") ready resolved, emitting`); + return new Promise((resolve, reject) => { + this.socket!.emit(event, ...args, (res: SocketAck) => { + console.log(`[AgentSocket] call("${event}") ack received:`, res?.ok, res?.error); + if (res.ok) resolve(res.data as T); + else reject(new Error(res.error ?? `${event} failed`)); + }); + }); + } + + getAutomations(): Promise { + return this.call('getAutomations'); + } + + createAutomation(automation: any): Promise { + return this.call('createAutomation', automation); + } + + updateAutomation(automation: any): Promise { + return this.call('updateAutomation', automation); + } + + async deleteAutomation(id: number): Promise { + await this.call('deleteAutomation', id); + } + + sendInstruction(instruction: string, response: 'text' | 'tts' = 'tts') { + const message = { + type: 'instruction', + data: { instruction }, + response, + }; + this.socket?.emit('message', message); + } + + sendTrigger(name: string, parameters: Record, response: 'text' | 'tts' = 'tts') { + const message = { + type: 'trigger', + data: { trigger: { name, parameters } }, + response, + }; + this.socket?.emit('message', message); + } +} diff --git a/app/services/stream-avatar/automations-engine-service.ts b/app/services/stream-avatar/automations-engine-service.ts new file mode 100644 index 000000000000..666ce3935f81 --- /dev/null +++ b/app/services/stream-avatar/automations-engine-service.ts @@ -0,0 +1,312 @@ +import { InitAfter, Inject, Service } from 'services/core'; +import { ScenesService } from 'services/scenes'; +import { SourcesService } from 'services/sources'; +import { StreamingService } from 'services/streaming'; +import { WebsocketService } from 'services/websocket'; +import { VisionService, VisionProcess } from 'services/vision'; +import { AutomationsService } from './automations-service'; +import Utils from 'services/utils'; +import { toUpper } from 'lodash'; +import { + ConditionsManager, + GAME_NAMES, + TCondition, + TEvaluatedCondition, +} from './engine/conditions'; +import { Actions, ActionContext } from './engine/actions'; +import { GameState, defaultGameState } from './engine/game-state'; + +interface VisionEventItem { + name: string; + data: { value: number }; +} + +interface VisionEventPayload { + events: VisionEventItem[]; + game: string; +} + +@InitAfter('VisionService') +export class AutomationsEngineService extends Service { + @Inject() private scenesService: ScenesService; + @Inject() private sourcesService: SourcesService; + @Inject() private streamingService: StreamingService; + @Inject() private websocketService: WebsocketService; + @Inject() private visionService: VisionService; + @Inject() private automationsService: AutomationsService; + + private gameState: GameState = { ...defaultGameState, pendingEvents: new Set() }; + private prevState: GameState = { ...defaultGameState, pendingEvents: new Set() }; + private activeProcess: VisionProcess | null = null; + private selectedGame = 'fortnite'; + private automationPreviousConditionsMetCache = new Map(); + private actionContext!: ActionContext; + + init() { + // Engine runs only in the worker window to prevent double-firing. + if (!Utils.isWorkerWindow()) return; + + this.actionContext = { + resolveSceneId: async ref => { + const scenes = this.scenesService.views.scenes; + const scene = + 'id' in ref ? scenes.find(s => s.id === ref.id) : scenes.find(s => s.name === ref.name); + if (!scene) throw new Error(`Scene not found: ${JSON.stringify(ref)}`); + return { id: scene.id, name: scene.name }; + }, + + resolveSourceId: async ref => { + const source = + 'id' in ref + ? this.sourcesService.views.getSource(ref.id) + : this.sourcesService.views.getSourcesByName(ref.name)[0]; + if (!source) throw new Error(`Source not found: ${JSON.stringify(ref)}`); + return { id: source.sourceId, name: source.name }; + }, + + switchScene: (id: string) => { + this.scenesService.makeSceneActive(id); + }, + + setSourceVisible: (id: string, visible: boolean) => { + const activeScene = this.scenesService.views.activeScene; + if (!activeScene) return; + for (const item of activeScene.getItems().filter(i => i.sourceId === id)) { + item.setVisibility(visible); + } + }, + + saveReplay: async () => { + if (!this.streamingService.views.isReplayBufferActive) { + this.streamingService.startReplayBuffer(); + await new Promise(resolve => setTimeout(resolve, 500)); + } + this.streamingService.saveReplay(); + }, + }; + + this.websocketService.socketEvent.subscribe(e => { + if (e.type !== 'visionEvent') return; + this.handleVisionEvent((e as any).message as VisionEventPayload); + }); + + this.visionService.onGame.subscribe(change => { + this.activeProcess = change.activeProcess; + this.selectedGame = change.selectedGame; + this.resetGameState(); + }); + } + + /** + * Runs an automation's actions on demand so the user can test it from the + * list. Fires every action as if its conditions were met, waits, then fires + * again with conditions unmet so conditional show/hide actions revert. + */ + async simulateAutomation(id: number): Promise { + const automation = this.automationsService.state.automations.find(a => a.id === id); + if (!automation) { + console.warn('[AutomationsEngine] simulateAutomation: automation not found', id); + return; + } + + const runPass = async (conditionsMet: boolean) => { + for (const exportedAction of automation.actions) { + try { + const [action] = await Actions.fromExported([exportedAction as any], this.actionContext); + await Actions.process({ + conditionsMet, + conditions: automation.conditions as TCondition[], + action: { ...action, props: { ...action.props, simulating: true } }, + context: this.actionContext, + state: this.gameState, + prevState: this.prevState, + }); + } catch (error) { + console.warn('[AutomationsEngine] simulate action failed', { + action: exportedAction.type, + error, + }); + } + } + }; + + await runPass(true); + await new Promise(resolve => setTimeout(resolve, 5000)); + await runPass(false); + } + + private resetGameState() { + this.gameState = { ...defaultGameState, pendingEvents: new Set() }; + this.prevState = { ...defaultGameState, pendingEvents: new Set() }; + this.automationPreviousConditionsMetCache.clear(); + } + + private getCurrentGame(): string { + const process = this.activeProcess; + if (!process) return 'STREAMLABS'; + + if (process.type === 'capture_device' || process.executable_name === 'vlc.exe') { + return this.visionService.state.availableGames[this.selectedGame] + ? toUpper(this.selectedGame) + : 'STREAMLABS'; + } + + return this.visionService.state.availableGames[process.game] + ? toUpper(process.game) + : 'STREAMLABS'; + } + + private handleVisionEvent(payload: VisionEventPayload) { + if (!(payload.game in GAME_NAMES)) return; + + this.prevState = { ...this.gameState, pendingEvents: new Set(this.gameState.pendingEvents) }; + + const newEvents: string[] = []; + const next: Partial = {}; + + for (const { name, data } of payload.events) { + switch (name) { + case 'game_start': + case 'round_start': + next.eliminations = 0; + next.teamScore = 0; + next.opponentScore = 0; + newEvents.push('game_start'); + break; + + case 'round_end': + case 'game_end': + Object.assign(next, defaultGameState); + newEvents.push('game_end'); + break; + + case 'health': + next.health = data.value; + break; + + case 'shield': + next.shield = data.value; + break; + + case 'elimination': + next.eliminations = (this.gameState.eliminations || 0) + 1; + newEvents.push('elimination'); + break; + + case 'team_scored': + next.teamScore = data.value; + newEvents.push('team_scored'); + break; + + case 'opponent_scored': + next.opponentScore = data.value; + newEvents.push('opponent_scored'); + break; + + case 'low_health': + next.health = 20; + break; + + case 'full_health': + next.health = 100; + break; + + case 'spectating': + case 'action_phase': + case 'deploy': + case 'victory': + case 'death': + case 'defeat': + case 'knockout': + case 'player_knocked': + case 'redeploying': + case 'storm_shrinking': + case 'gulag_start': + case 'gulag_end': + case 'first_half': + case 'second_half': + case 'round_won': + case 'round_lost': + case 'enemy_spotted': + case 'enemy_detected': + case 'interesting_moment': + case 'ender_dragon_spawned': + case 'boss_killed': + case 'wither_spawned': + case 'advancement_made': + case 'first_diamond': + case 'nether_entered': + case 'totem_of_undying_used': + case 'player_revived': + case 'hooked_survivor': + case 'escaped': + case 'tower_destroyed': + case 'glyph_used': + case 'objective_ally': + case 'objective_enemy': + case 'enemy_turret_destroyed': + case 'ally_turret_destroyed': + case 'position_change': + case 'lap_change': + case 'goal': + case 'set_piece': + case 'halftime': + case 'fulltime': + newEvents.push(name); + break; + + default: + break; + } + } + + this.gameState = { ...this.gameState, ...next, pendingEvents: new Set(newEvents) }; + + if (newEvents.length > 0 || Object.keys(next).length > 0) { + this.checkAndTriggerAutomations(); + queueMicrotask(() => { + this.gameState = { ...this.gameState, pendingEvents: new Set() }; + }); + } + } + + private async checkAndTriggerAutomations() { + const automations = this.automationsService.state.automations.filter(a => a.enabled); + const state = this.gameState; + const prevState = this.prevState; + const currentGame = this.getCurrentGame().toLowerCase(); + + for (const automation of automations) { + const conditionResults: TEvaluatedCondition[] = []; + + for (const condition of automation.conditions as TCondition[]) { + const status = ConditionsManager.evaluate({ condition, state, prevState }); + const cachedStatus = this.automationPreviousConditionsMetCache.get(automation.id!); + if (cachedStatus === status) continue; + if (!condition.type.startsWith(currentGame)) continue; + this.automationPreviousConditionsMetCache.set(automation.id!, status); + conditionResults.push({ condition, status }); + } + + if (!conditionResults.length) continue; + + const conditionsNotMet = conditionResults.some(r => !r.status); + + for (const exportedAction of automation.actions) { + try { + const [action] = await Actions.fromExported([exportedAction as any], this.actionContext); + await Actions.process({ + conditionsMet: !conditionsNotMet, + conditions: conditionResults.map(r => r.condition), + action, + context: this.actionContext, + state, + prevState, + }); + } catch (error) { + console.warn('[AutomationsEngine] Action failed', { action: exportedAction.type, error }); + } + } + } + } +} diff --git a/app/services/stream-avatar/automations-service.ts b/app/services/stream-avatar/automations-service.ts new file mode 100644 index 000000000000..b8cd542bbf8c --- /dev/null +++ b/app/services/stream-avatar/automations-service.ts @@ -0,0 +1,111 @@ +import { InitAfter } from 'services/core'; +import { StatefulService, mutation } from 'services/core/stateful-service'; +import { Inject } from 'services/core/injector'; +import { AgentSocketService } from './agent-socket-service'; +import { UserService } from 'services/user'; +import { WindowsService } from 'services/windows'; +import { $t } from 'services/i18n'; +import Utils from 'services/utils'; +import type { TAutomationExport } from './engine/automations'; + +interface IAutomationsState { + automations: TAutomationExport[]; + loading: boolean; + loaded: boolean; +} + +@InitAfter('UserService') +export class AutomationsService extends StatefulService { + static initialState: IAutomationsState = { + automations: [], + loading: false, + loaded: false, + }; + + @Inject() private agentSocketService: AgentSocketService; + @Inject() private userService: UserService; + @Inject() private windowsService: WindowsService; + + init() { + console.log('[AutomationsService] init() called. isWorkerWindow:', Utils.isWorkerWindow()); + if (!Utils.isWorkerWindow()) return; + console.log('[AutomationsService] init() isLoggedIn:', this.userService.isLoggedIn); + if (this.userService.isLoggedIn) { + this.fetchAll(); + } + this.userService.userLogin.subscribe(() => { + console.log('[AutomationsService] userLogin fired, fetching'); + this.fetchAll(); + }); + } + + showAutomations() { + this.windowsService.showWindow({ + componentName: 'EditAutomations', + title: $t('Automations'), + size: { + width: 900, + height: 650, + }, + }); + } + + @mutation() + private SET_AUTOMATIONS(automations: TAutomationExport[]) { + this.state.automations = automations; + this.state.loaded = true; + this.state.loading = false; + } + + @mutation() + private SET_LOADING(loading: boolean) { + this.state.loading = loading; + } + + @mutation() + private ADD_AUTOMATION(automation: TAutomationExport) { + this.state.automations = [automation, ...this.state.automations]; + } + + @mutation() + private UPDATE_AUTOMATION(automation: TAutomationExport) { + this.state.automations = this.state.automations.map(a => + a.id === automation.id ? automation : a, + ); + } + + @mutation() + private REMOVE_AUTOMATION(id: number) { + this.state.automations = this.state.automations.filter(a => a.id !== id); + } + + async fetchAll(): Promise { + console.log('[AutomationsService] fetchAll() start'); + this.SET_LOADING(true); + try { + const automations = await this.agentSocketService.getAutomations(); + console.log('[AutomationsService] fetchAll() got', automations?.length, 'automations'); + this.SET_AUTOMATIONS(automations as TAutomationExport[]); + } catch (e) { + this.SET_LOADING(false); + console.error('[AutomationsService] fetchAll failed', e); + } + } + + async create(automation: Omit): Promise { + const created = await this.agentSocketService.createAutomation(automation); + this.ADD_AUTOMATION(created as TAutomationExport); + return created as TAutomationExport; + } + + async update(id: number, automation: Partial): Promise { + const updated = await this.agentSocketService.updateAutomation({ ...automation, id }); + this.UPDATE_AUTOMATION(updated as TAutomationExport); + return updated as TAutomationExport; + } + + async remove(id: number): Promise { + await this.agentSocketService.deleteAutomation(id); + this.REMOVE_AUTOMATION(id); + } +} diff --git a/app/services/stream-avatar/engine/actions.ts b/app/services/stream-avatar/engine/actions.ts new file mode 100644 index 000000000000..f62de788b506 --- /dev/null +++ b/app/services/stream-avatar/engine/actions.ts @@ -0,0 +1,300 @@ +import uuid from 'uuid/v4'; +import { Properties, PropertyInstance, PropertyMap } from './properties'; +import { Instructions } from './instructions'; +import type { TCondition } from './conditions'; +import type { GameState } from './game-state'; + +export type ResolveFromIdOrName = { name: string; id?: never } | { id: string; name?: never }; + +export type SceneRef = { id: string; name: string }; +export type SourceRef = { id: string; name: string }; + +export type ActionContext = { + resolveSceneId: (scene: ResolveFromIdOrName) => Promise; + resolveSourceId: (source: ResolveFromIdOrName) => Promise; + switchScene: (id: string) => void; + setSourceVisible: (id: string, visible: boolean) => void; + saveReplay: () => Promise; +}; + +export type ActionProps = { + scene?: { id: string }; + source?: { id: string }; + show_if_condition_false?: boolean; + hide_if_condition_false?: boolean; + duration?: number; + instruction?: string; + say?: string; + simulating?: boolean; +}; + +export type ExportedActionProps = { + scene?: { name: string }; + source?: { name: string }; + show_if_condition_false?: boolean; + hide_if_condition_false?: boolean; + duration?: number; + instruction?: string; + say?: string; +}; + +type ActionProcessPayload = { + conditionsMet: boolean; + conditions: TCondition[]; + context: ActionContext; + props?: ActionProps; + state: GameState; + prevState: GameState; +}; + +export type ActionDef = { + group: string; + name: string; + label: string; + properties?: PropertyMap; + process: (payload: ActionProcessPayload) => Promise; +}; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export const ActionRegistry = { + 'common.switch_to_scene': { + group: 'common', + name: 'switch_to_scene', + label: 'Switch to Scene', + properties: { + scene: new Properties.Scene({ label: 'Scene' }), + }, + process: async ({ conditionsMet, props, context }) => { + if (!conditionsMet || !props?.scene) return; + context.switchScene(props.scene.id); + }, + }, + + 'common.hide_source': { + group: 'common', + name: 'hide_source', + label: 'Hide Source', + properties: { + source: new Properties.Source({ label: 'Source' }), + show_if_condition_false: new Properties.Checkbox({ label: 'Show if condition is false' }), + }, + process: async ({ conditionsMet, props, context }) => { + if (!props?.source) return; + if (!conditionsMet) { + if (props.show_if_condition_false) { + context.setSourceVisible(props.source.id, true); + } + return; + } + context.setSourceVisible(props.source.id, false); + }, + }, + + 'common.show_source': { + group: 'common', + name: 'show_source', + label: 'Show Source', + properties: { + source: new Properties.Source({ label: 'Source' }), + hide_if_condition_false: new Properties.Checkbox({ label: 'Hide if condition is false' }), + }, + process: async ({ conditionsMet, props, context }) => { + if (!props?.source) return; + if (!conditionsMet) { + if (props.hide_if_condition_false) { + context.setSourceVisible(props.source.id, false); + } + return; + } + context.setSourceVisible(props.source.id, true); + }, + }, + + 'common.save_replay': { + group: 'common', + name: 'save_replay', + label: 'Save Replay', + process: async ({ conditionsMet, context }) => { + if (!conditionsMet) return; + await context.saveReplay(); + }, + }, + + 'common.wait_for_ms': { + group: 'common', + name: 'wait_for_ms', + label: 'Wait', + properties: { + duration: new Properties.Slider({ + label: 'Duration', + min: 500, + max: 60000, + default: 5000, + step: 500, + format: (ms: number) => `${(ms / 1000).toFixed(1)} ${ms === 1000 ? 'second' : 'seconds'}`, + }), + }, + process: async ({ conditionsMet, props }) => { + if (!conditionsMet || typeof props?.duration !== 'number') return; + await sleep(props.duration); + }, + }, + + 'co-host.comment': { + group: 'co-host', + name: 'comment', + label: 'Co-host Comment', + properties: {}, + process: async ({ conditionsMet, conditions }) => { + if (!conditionsMet) return; + for (const c of conditions) { + const instruction = Instructions[c.type as keyof typeof Instructions]; + console.log('[AutomationsEngine] co-host.comment', { condition: c.type, instruction }); + } + }, + }, + + 'co-host.instruction': { + group: 'co-host', + name: 'instruction', + label: 'Co-host Instruction', + properties: { + instruction: new Properties.Text({ label: 'Instruction' }), + }, + process: async ({ conditionsMet, props }) => { + if (!conditionsMet || !props?.instruction) return; + console.log('[AutomationsEngine] co-host.instruction', { instruction: props.instruction }); + }, + }, +} as const satisfies Record; + +export type ActionType = keyof typeof ActionRegistry; + +export type Action = { + id: string; + type: ActionType; + props?: ActionProps; +}; + +export type ExportedAction = { + type: ActionType; + props?: ExportedActionProps; +}; + +/** + * Reads the registry's property defaults for an action type into exported-props + * shape (e.g. wait_for_ms's 5000ms duration). Only primitive properties define + * defaults, and their exported form equals their internal form, so the config + * default can be used directly. + */ +export function defaultExportedProps(type: ActionType): ExportedActionProps | undefined { + const def = ActionRegistry[type]; + if (!('properties' in def) || !def.properties) return undefined; + + const props: Partial = {}; + for (const [key, property] of Object.entries(def.properties as Record)) { + const value = (property as any).config?.default; + if (value !== undefined) props[key as keyof ExportedActionProps] = value as never; + } + + return Object.keys(props).length ? (props as ExportedActionProps) : undefined; +} + +/** + * Ensures an action carries its property defaults so they are persisted rather + * than only shown as a UI placeholder. Existing props win over defaults. + */ +export function withActionDefaults(action: ExportedAction): ExportedAction { + const defaults = defaultExportedProps(action.type); + if (!defaults) return action; + return { ...action, props: { ...defaults, ...action.props } }; +} + +const valueFromExport = ( + property: PropertyInstance, + v: unknown, + context: ActionContext, +): Promise | unknown => + (property as any).valueFromExport(v, context); + +const valueToExport = ( + property: PropertyInstance, + v: unknown, + context: ActionContext, +): Promise | unknown => + (property as any).valueToExport(v, context); + +const importAction = async (exported: ExportedAction, context: ActionContext): Promise => { + const def = ActionRegistry[exported.type]; + if (!def) throw new Error(`Action ${exported.type} not found`); + + if (!('properties' in def) || !def.properties) { + return { id: uuid(), type: exported.type }; + } + + const props: Partial = {}; + for (const [key, property] of Object.entries(def.properties as Record)) { + const propValue = exported.props?.[key as keyof ExportedActionProps] as unknown; + props[key as keyof ActionProps] = (await valueFromExport(property, propValue, context)) as never; + } + + return { + id: uuid(), + type: exported.type, + props: Object.keys(props).length ? (props as ActionProps) : undefined, + }; +}; + +const exportAction = async (action: Action, context: ActionContext): Promise => { + const def = ActionRegistry[action.type]; + if (!def) throw new Error(`Action ${action.type} not found`); + + if (!('properties' in def) || !def.properties) { + return { type: action.type }; + } + + const props: Partial = {}; + for (const [key, property] of Object.entries(def.properties as Record)) { + const propValue = action.props?.[key as keyof ActionProps] as unknown; + props[key as keyof ExportedActionProps] = (await valueToExport(property, propValue, context)) as never; + } + + return { + type: action.type, + props: Object.keys(props).length ? (props as ExportedActionProps) : undefined, + }; +}; + +export class Actions { + static async fromExported(exported: ExportedAction[], context: ActionContext): Promise { + return Promise.all(exported.map(a => importAction(a, context))); + } + + static async toExported(actions: Action[], context: ActionContext): Promise { + return Promise.all(actions.map(a => exportAction(a, context))); + } + + static async process({ + action, + conditionsMet, + conditions, + context, + state, + prevState, + }: { + action: Action; + conditionsMet: boolean; + conditions: TCondition[]; + context: ActionContext; + state: GameState; + prevState: GameState; + }) { + const def = ActionRegistry[action.type]; + if (!def) throw new Error(`Action ${action.type} not found`); + + return def.process({ conditionsMet, conditions, context, props: action.props, state, prevState }); + } +} diff --git a/app/services/stream-avatar/engine/automations.ts b/app/services/stream-avatar/engine/automations.ts new file mode 100644 index 000000000000..ddc33c2ea7bf --- /dev/null +++ b/app/services/stream-avatar/engine/automations.ts @@ -0,0 +1,18 @@ +import type { Action, ExportedAction } from './actions'; +import type { TCondition } from './conditions'; + +export type TAutomation = { + id?: number; + description?: string; + conditions: TCondition[]; + actions: Action[]; + enabled: boolean; +}; + +export type TAutomationExport = { + id?: number; + description?: string; + conditions: TCondition[]; + actions: ExportedAction[]; + enabled: boolean; +}; diff --git a/app/services/stream-avatar/engine/conditions/apexlegends.ts b/app/services/stream-avatar/engine/conditions/apexlegends.ts new file mode 100644 index 000000000000..3fb92f776cf3 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/apexlegends.ts @@ -0,0 +1,114 @@ +import { ConditionDefinition } from '.'; + +export type ApexLegendsConditionPropsMap = { + //---------------------- + // Apex Legends + //---------------------- + + // Game Flow + 'apex_legends.game_start': undefined; + 'apex_legends.deploy': undefined; + 'apex_legends.storm_shrinking': undefined; + 'apex_legends.game_end': undefined; + + // Player + 'apex_legends.player_knocked': undefined; + 'apex_legends.player_revived': undefined; + 'apex_legends.player_eliminated': undefined; + + // Win / Lose + 'apex_legends.victory': undefined; + 'apex_legends.defeat': undefined; + + // Enemy + 'apex_legends.elimination': undefined; + 'apex_legends.knockout': undefined; +}; + +export type ApexLegendsConditionType = keyof ApexLegendsConditionPropsMap; +export type ApexLegendsConditionProps< + T extends ApexLegendsConditionType +> = ApexLegendsConditionPropsMap[T]; + +export const ApexLegendsConditions: { + [K in ApexLegendsConditionType]: ConditionDefinition; +} = { + 'apex_legends.game_start': { + group: 'apex_legends', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'apex_legends.deploy': { + group: 'apex_legends', + name: 'deploy', + label: 'Deployed', + evaluate: ({ state }) => state.pendingEvents.has('deploy'), + }, + + 'apex_legends.storm_shrinking': { + group: 'apex_legends', + name: 'storm_shrinking', + label: 'Ring Closing', + evaluate: ({ state }) => state.pendingEvents.has('storm_shrinking'), + }, + + 'apex_legends.game_end': { + group: 'apex_legends', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'apex_legends.player_knocked': { + group: 'apex_legends', + name: 'player_knocked', + label: 'Player Knocked', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + 'apex_legends.player_revived': { + group: 'apex_legends', + name: 'player_revived', + label: 'Player Revived', + evaluate: ({ state }) => state.pendingEvents.has('player_revived'), + }, + + 'apex_legends.player_eliminated': { + group: 'apex_legends', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'apex_legends.victory': { + group: 'apex_legends', + name: 'victory', + label: 'Victory (Champion)', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'apex_legends.defeat': { + group: 'apex_legends', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'apex_legends.elimination': { + group: 'apex_legends', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'apex_legends.knockout': { + group: 'apex_legends', + name: 'knockout', + label: 'Enemy Knocked', + evaluate: ({ state }) => state.pendingEvents.has('knockout'), + }, +} as const; + +export default ApexLegendsConditions; diff --git a/app/services/stream-avatar/engine/conditions/arcraiders.ts b/app/services/stream-avatar/engine/conditions/arcraiders.ts new file mode 100644 index 000000000000..10ed07e9ab4e --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/arcraiders.ts @@ -0,0 +1,100 @@ +import { ConditionDefinition } from '.'; + +export type ArcRaidersConditionPropsMap = { + //---------------------- + // Arc Raiders + //---------------------- + + // Game Flow + 'arc_raiders.game_start': undefined; + 'arc_raiders.game_end': undefined; + + // Win / Lose + 'arc_raiders.victory': undefined; + 'arc_raiders.defeat': undefined; + + // Player + 'arc_raiders.player_knocked': undefined; + 'arc_raiders.player_eliminated': undefined; + + // Enemy + 'arc_raiders.enemy_spotted': undefined; + 'arc_raiders.enemy_detected': undefined; + + // Moments + 'arc_raiders.interesting_moment': undefined; +}; + +export type ArcRaidersConditionType = keyof ArcRaidersConditionPropsMap; +export type ArcRaidersConditionProps< + T extends ArcRaidersConditionType +> = ArcRaidersConditionPropsMap[T]; + +export const ArcRaidersConditions: { + [K in ArcRaidersConditionType]: ConditionDefinition; +} = { + 'arc_raiders.game_start': { + group: 'arc_raiders', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'arc_raiders.game_end': { + group: 'arc_raiders', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'arc_raiders.victory': { + group: 'arc_raiders', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'arc_raiders.defeat': { + group: 'arc_raiders', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'arc_raiders.player_knocked': { + group: 'arc_raiders', + name: 'player_knocked', + label: 'Player Knocked', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + 'arc_raiders.player_eliminated': { + group: 'arc_raiders', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'arc_raiders.enemy_spotted': { + group: 'arc_raiders', + name: 'enemy_spotted', + label: 'Enemy Spotted', + evaluate: ({ state }) => state.pendingEvents.has('enemy_spotted'), + }, + + 'arc_raiders.enemy_detected': { + group: 'arc_raiders', + name: 'enemy_detected', + label: 'Enemy Detected', + evaluate: ({ state }) => state.pendingEvents.has('enemy_detected'), + }, + + 'arc_raiders.interesting_moment': { + group: 'arc_raiders', + name: 'interesting_moment', + label: 'Interesting Moment', + evaluate: ({ state }) => state.pendingEvents.has('interesting_moment'), + }, +} as const; + +export default ArcRaidersConditions; diff --git a/app/services/stream-avatar/engine/conditions/battlefield6.ts b/app/services/stream-avatar/engine/conditions/battlefield6.ts new file mode 100644 index 000000000000..69a492b25aa6 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/battlefield6.ts @@ -0,0 +1,72 @@ +import { ConditionDefinition } from '.'; + +export type Battlefield6ConditionPropsMap = { + //---------------------- + // Battlefield 6 + //---------------------- + + // Game Flow + 'battlefield_6.game_start': undefined; + 'battlefield_6.game_end': undefined; + + // Win / Lose + 'battlefield_6.victory': undefined; + 'battlefield_6.defeat': undefined; + + // Combat + 'battlefield_6.elimination': undefined; + 'battlefield_6.player_eliminated': undefined; +}; + +export type Battlefield6ConditionType = keyof Battlefield6ConditionPropsMap; +export type Battlefield6ConditionProps< + T extends Battlefield6ConditionType +> = Battlefield6ConditionPropsMap[T]; + +export const Battlefield6Conditions: { + [K in Battlefield6ConditionType]: ConditionDefinition; +} = { + 'battlefield_6.game_start': { + group: 'battlefield_6', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'battlefield_6.game_end': { + group: 'battlefield_6', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'battlefield_6.victory': { + group: 'battlefield_6', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'battlefield_6.defeat': { + group: 'battlefield_6', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'battlefield_6.elimination': { + group: 'battlefield_6', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'battlefield_6.player_eliminated': { + group: 'battlefield_6', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, +} as const; + +export default Battlefield6Conditions; diff --git a/app/services/stream-avatar/engine/conditions/blackops6.ts b/app/services/stream-avatar/engine/conditions/blackops6.ts new file mode 100644 index 000000000000..202c9134b0a7 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/blackops6.ts @@ -0,0 +1,54 @@ +import { ConditionDefinition } from '.'; + +export type BlackOps6ConditionPropsMap = { + //---------------------- + // Call of Duty: Black Ops 6 + //---------------------- + + // Player + 'black_ops_6.elimination': undefined; + 'black_ops_6.victory': undefined; + 'black_ops_6.defeat': undefined; + + // Game Flow + 'black_ops_6.spectating': undefined; +}; + +export type BlackOps6ConditionType = keyof BlackOps6ConditionPropsMap; +export type BlackOps6ConditionProps< + T extends BlackOps6ConditionType +> = BlackOps6ConditionPropsMap[T]; + +export const BlackOps6Conditions: { + [K in BlackOps6ConditionType]: ConditionDefinition; +} = { + 'black_ops_6.elimination': { + group: 'black_ops_6', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'black_ops_6.victory': { + group: 'black_ops_6', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'black_ops_6.defeat': { + group: 'black_ops_6', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'black_ops_6.spectating': { + group: 'black_ops_6', + name: 'spectating', + label: 'Spectating', + evaluate: ({ state }) => state.pendingEvents.has('spectating'), + }, +} as const; + +export default BlackOps6Conditions; diff --git a/app/services/stream-avatar/engine/conditions/counterstrike2.ts b/app/services/stream-avatar/engine/conditions/counterstrike2.ts new file mode 100644 index 000000000000..092bda57ad09 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/counterstrike2.ts @@ -0,0 +1,146 @@ +import { Properties } from '../properties'; +import { ConditionDefinition } from '.'; + +export type CounterStrike2ConditionPropsMap = { + //---------------------- + // Counter-Strike 2 + //---------------------- + + // Game Flow + 'counter_strike_2.round_started': undefined; + 'counter_strike_2.first_half': undefined; + 'counter_strike_2.second_half': undefined; + 'counter_strike_2.round_won': undefined; + 'counter_strike_2.round_lost': undefined; + 'counter_strike_2.game_ended': undefined; + + // Health Shield + 'counter_strike_2.low_health': undefined; + + // Player + 'counter_strike_2.victory': undefined; + 'counter_strike_2.player_eliminated': undefined; + 'counter_strike_2.defeat': undefined; + + // Enemy + 'counter_strike_2.elimination': undefined; + 'counter_strike_2.elimination_count': { + elimination_count?: [number, number]; + }; +}; + +export type CounterStrike2ConditionType = keyof CounterStrike2ConditionPropsMap; +export type CounterStrike2ConditionProps< + T extends CounterStrike2ConditionType +> = CounterStrike2ConditionPropsMap[T]; + +export const CounterStrike2Conditions: { + [K in CounterStrike2ConditionType]: ConditionDefinition; +} = { + // Game Flow + 'counter_strike_2.round_started': { + group: 'counter_strike_2', + name: 'round_started', + label: 'Round Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + // Health / Shield Conditions + 'counter_strike_2.low_health': { + group: 'counter_strike_2', + name: 'low_health', + label: 'Low Health', + evaluate: ({ state }) => { + const { health = 0 } = state; + return health > 0 && health < 50; + }, + }, + + // Player + 'counter_strike_2.victory': { + group: 'counter_strike_2', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'counter_strike_2.defeat': { + group: 'counter_strike_2', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'counter_strike_2.player_eliminated': { + group: 'counter_strike_2', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + // Enemy + 'counter_strike_2.elimination': { + group: 'counter_strike_2', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'counter_strike_2.elimination_count': { + group: 'counter_strike_2', + name: 'elimination_count', + label: 'Enemy Elimination Count', + properties: { + elimination_count: new Properties.SliderRange({ + label: '# of Eliminations', + min: 0, + max: 50, + default: [5, 5], + step: 1, + }), + }, + evaluate: ({ state, prevState, props }) => { + const [min, max] = props?.elimination_count ?? [5, 5]; + const { eliminations = 0 } = state; + const { eliminations: prevEliminations = 0 } = prevState; + return eliminations >= min && prevEliminations <= max; + }, + }, + + 'counter_strike_2.first_half': { + group: 'counter_strike_2', + name: 'first_half', + label: 'First Half', + evaluate: ({ state }) => state.pendingEvents.has('first_half'), + }, + + 'counter_strike_2.second_half': { + group: 'counter_strike_2', + name: 'second_half', + label: 'Second Half', + evaluate: ({ state }) => state.pendingEvents.has('second_half'), + }, + + 'counter_strike_2.round_won': { + group: 'counter_strike_2', + name: 'round_won', + label: 'Round Won', + evaluate: ({ state }) => state.pendingEvents.has('round_won'), + }, + + 'counter_strike_2.round_lost': { + group: 'counter_strike_2', + name: 'round_lost', + label: 'Round Lost', + evaluate: ({ state }) => state.pendingEvents.has('round_lost'), + }, + + 'counter_strike_2.game_ended': { + group: 'counter_strike_2', + name: 'game_ended', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, +} as const; + +export default CounterStrike2Conditions; diff --git a/app/services/stream-avatar/engine/conditions/deadbydaylight.ts b/app/services/stream-avatar/engine/conditions/deadbydaylight.ts new file mode 100644 index 000000000000..b6763b6163cc --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/deadbydaylight.ts @@ -0,0 +1,80 @@ +import { ConditionDefinition } from '.'; + +export type DeadByDaylightConditionPropsMap = { + //---------------------- + // Dead by Daylight + //---------------------- + + // Game Flow + 'dead_by_daylight.game_start': undefined; + 'dead_by_daylight.game_end': undefined; + + // Win / Lose + 'dead_by_daylight.victory': undefined; + 'dead_by_daylight.player_eliminated': undefined; + + // Combat / Events + 'dead_by_daylight.elimination': undefined; + 'dead_by_daylight.hooked_survivor': undefined; + 'dead_by_daylight.escaped': undefined; +}; + +export type DeadByDaylightConditionType = keyof DeadByDaylightConditionPropsMap; +export type DeadByDaylightConditionProps< + T extends DeadByDaylightConditionType +> = DeadByDaylightConditionPropsMap[T]; + +export const DeadByDaylightConditions: { + [K in DeadByDaylightConditionType]: ConditionDefinition; +} = { + 'dead_by_daylight.game_start': { + group: 'dead_by_daylight', + name: 'game_start', + label: 'Match Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'dead_by_daylight.game_end': { + group: 'dead_by_daylight', + name: 'game_end', + label: 'Match Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'dead_by_daylight.victory': { + group: 'dead_by_daylight', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'dead_by_daylight.player_eliminated': { + group: 'dead_by_daylight', + name: 'player_eliminated', + label: 'Player Sacrificed / Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'dead_by_daylight.elimination': { + group: 'dead_by_daylight', + name: 'elimination', + label: 'Survivor Sacrificed (Killer)', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'dead_by_daylight.hooked_survivor': { + group: 'dead_by_daylight', + name: 'hooked_survivor', + label: 'Survivor Hooked', + evaluate: ({ state }) => state.pendingEvents.has('hooked_survivor'), + }, + + 'dead_by_daylight.escaped': { + group: 'dead_by_daylight', + name: 'escaped', + label: 'Survivor Escaped', + evaluate: ({ state }) => state.pendingEvents.has('escaped'), + }, +} as const; + +export default DeadByDaylightConditions; diff --git a/app/services/stream-avatar/engine/conditions/deadlock.ts b/app/services/stream-avatar/engine/conditions/deadlock.ts new file mode 100644 index 000000000000..4b8ae977c8ed --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/deadlock.ts @@ -0,0 +1,70 @@ +import { ConditionDefinition } from '.'; + +export type DeadlockConditionPropsMap = { + //---------------------- + // Deadlock + //---------------------- + + // Game Flow + 'deadlock.game_start': undefined; + 'deadlock.game_end': undefined; + + // Win / Lose + 'deadlock.victory': undefined; + 'deadlock.defeat': undefined; + + // Combat + 'deadlock.elimination': undefined; + 'deadlock.player_eliminated': undefined; +}; + +export type DeadlockConditionType = keyof DeadlockConditionPropsMap; +export type DeadlockConditionProps = DeadlockConditionPropsMap[T]; + +export const DeadlockConditions: { + [K in DeadlockConditionType]: ConditionDefinition; +} = { + 'deadlock.game_start': { + group: 'deadlock', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'deadlock.game_end': { + group: 'deadlock', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'deadlock.victory': { + group: 'deadlock', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'deadlock.defeat': { + group: 'deadlock', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'deadlock.elimination': { + group: 'deadlock', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'deadlock.player_eliminated': { + group: 'deadlock', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, +} as const; + +export default DeadlockConditions; diff --git a/app/services/stream-avatar/engine/conditions/dota2.ts b/app/services/stream-avatar/engine/conditions/dota2.ts new file mode 100644 index 000000000000..9b0ae93e9dae --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/dota2.ts @@ -0,0 +1,88 @@ +import { ConditionDefinition } from '.'; + +export type Dota2ConditionPropsMap = { + //---------------------- + // Dota 2 + //---------------------- + + // Game Flow + 'dota_2.game_start': undefined; + 'dota_2.game_end': undefined; + + // Win / Lose + 'dota_2.victory': undefined; + 'dota_2.defeat': undefined; + + // Combat + 'dota_2.elimination': undefined; + 'dota_2.player_eliminated': undefined; + + // Objectives + 'dota_2.tower_destroyed': undefined; + 'dota_2.glyph_used': undefined; +}; + +export type Dota2ConditionType = keyof Dota2ConditionPropsMap; +export type Dota2ConditionProps = Dota2ConditionPropsMap[T]; + +export const Dota2Conditions: { + [K in Dota2ConditionType]: ConditionDefinition; +} = { + 'dota_2.game_start': { + group: 'dota_2', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'dota_2.game_end': { + group: 'dota_2', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'dota_2.victory': { + group: 'dota_2', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'dota_2.defeat': { + group: 'dota_2', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'dota_2.elimination': { + group: 'dota_2', + name: 'elimination', + label: 'Hero Kill', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'dota_2.player_eliminated': { + group: 'dota_2', + name: 'player_eliminated', + label: 'Player Died', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'dota_2.tower_destroyed': { + group: 'dota_2', + name: 'tower_destroyed', + label: 'Tower Destroyed', + evaluate: ({ state }) => state.pendingEvents.has('tower_destroyed'), + }, + + 'dota_2.glyph_used': { + group: 'dota_2', + name: 'glyph_used', + label: 'Glyph of Fortification Used', + evaluate: ({ state }) => state.pendingEvents.has('glyph_used'), + }, +} as const; + +export default Dota2Conditions; diff --git a/app/services/stream-avatar/engine/conditions/easportsfc26.ts b/app/services/stream-avatar/engine/conditions/easportsfc26.ts new file mode 100644 index 000000000000..66ddb71860c8 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/easportsfc26.ts @@ -0,0 +1,70 @@ +import { ConditionDefinition } from '.'; + +export type EaSportsFc26ConditionPropsMap = { + //---------------------- + // EA Sports FC 26 + //---------------------- + + // Game Flow + 'ea_sports_fc_26.game_start': undefined; + 'ea_sports_fc_26.game_end': undefined; + + // Match Events + 'ea_sports_fc_26.goal': undefined; + 'ea_sports_fc_26.set_piece': undefined; + 'ea_sports_fc_26.halftime': undefined; + 'ea_sports_fc_26.fulltime': undefined; +}; + +export type EaSportsFc26ConditionType = keyof EaSportsFc26ConditionPropsMap; +export type EaSportsFc26ConditionProps< + T extends EaSportsFc26ConditionType +> = EaSportsFc26ConditionPropsMap[T]; + +export const EaSportsFc26Conditions: { + [K in EaSportsFc26ConditionType]: ConditionDefinition; +} = { + 'ea_sports_fc_26.game_start': { + group: 'ea_sports_fc_26', + name: 'game_start', + label: 'Match Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'ea_sports_fc_26.game_end': { + group: 'ea_sports_fc_26', + name: 'game_end', + label: 'Match Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'ea_sports_fc_26.goal': { + group: 'ea_sports_fc_26', + name: 'goal', + label: 'Goal Scored', + evaluate: ({ state }) => state.pendingEvents.has('goal'), + }, + + 'ea_sports_fc_26.set_piece': { + group: 'ea_sports_fc_26', + name: 'set_piece', + label: 'Set Piece', + evaluate: ({ state }) => state.pendingEvents.has('set_piece'), + }, + + 'ea_sports_fc_26.halftime': { + group: 'ea_sports_fc_26', + name: 'halftime', + label: 'Half Time', + evaluate: ({ state }) => state.pendingEvents.has('halftime'), + }, + + 'ea_sports_fc_26.fulltime': { + group: 'ea_sports_fc_26', + name: 'fulltime', + label: 'Full Time', + evaluate: ({ state }) => state.pendingEvents.has('fulltime'), + }, +} as const; + +export default EaSportsFc26Conditions; diff --git a/app/services/stream-avatar/engine/conditions/enshrouded.ts b/app/services/stream-avatar/engine/conditions/enshrouded.ts new file mode 100644 index 000000000000..c32cd58d85d0 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/enshrouded.ts @@ -0,0 +1,56 @@ +import { ConditionDefinition } from '.'; + +export type EnshroudedConditionPropsMap = { + //---------------------- + // Enshrouded + //---------------------- + + // Player Events + 'enshrouded.player_eliminated': undefined; + 'enshrouded.level_up': undefined; + + // Exploration + 'enshrouded.soul_discovered': undefined; + + // Quests + 'enshrouded.quest_update': undefined; +}; + +export type EnshroudedConditionType = keyof EnshroudedConditionPropsMap; +export type EnshroudedConditionProps< + T extends EnshroudedConditionType +> = EnshroudedConditionPropsMap[T]; + +export const EnshroudedConditions: { + [K in EnshroudedConditionType]: ConditionDefinition; +} = { + 'enshrouded.player_eliminated': { + group: 'enshrouded', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'enshrouded.level_up': { + group: 'enshrouded', + name: 'level_up', + label: 'Level Up', + evaluate: ({ state }) => state.pendingEvents.has('level_up'), + }, + + 'enshrouded.soul_discovered': { + group: 'enshrouded', + name: 'soul_discovered', + label: 'Soul Discovered', + evaluate: ({ state }) => state.pendingEvents.has('soul_discovered'), + }, + + 'enshrouded.quest_update': { + group: 'enshrouded', + name: 'quest_update', + label: 'Quest Updated', + evaluate: ({ state }) => state.pendingEvents.has('quest_update'), + }, +} as const; + +export default EnshroudedConditions; diff --git a/app/services/stream-avatar/engine/conditions/f125.ts b/app/services/stream-avatar/engine/conditions/f125.ts new file mode 100644 index 000000000000..c8d5f78667ce --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/f125.ts @@ -0,0 +1,62 @@ +import { ConditionDefinition } from '.'; + +export type F125ConditionPropsMap = { + //---------------------- + // F1 25 + //---------------------- + + // Game Flow + 'f1_25.game_start': undefined; + 'f1_25.game_end': undefined; + + // Win + 'f1_25.victory': undefined; + + // Race Events + 'f1_25.position_change': undefined; + 'f1_25.lap_change': undefined; +}; + +export type F125ConditionType = keyof F125ConditionPropsMap; +export type F125ConditionProps = F125ConditionPropsMap[T]; + +export const F125Conditions: { + [K in F125ConditionType]: ConditionDefinition; +} = { + 'f1_25.game_start': { + group: 'f1_25', + name: 'game_start', + label: 'Race Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'f1_25.game_end': { + group: 'f1_25', + name: 'game_end', + label: 'Race Ended (Chequered Flag)', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'f1_25.victory': { + group: 'f1_25', + name: 'victory', + label: 'Race Win (P1)', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'f1_25.position_change': { + group: 'f1_25', + name: 'position_change', + label: 'Race Position Changed', + evaluate: ({ state }) => state.pendingEvents.has('position_change'), + }, + + 'f1_25.lap_change': { + group: 'f1_25', + name: 'lap_change', + label: 'New Lap Started', + evaluate: ({ state }) => state.pendingEvents.has('lap_change'), + }, +} as const; + +export default F125Conditions; diff --git a/app/services/stream-avatar/engine/conditions/fortnite.ts b/app/services/stream-avatar/engine/conditions/fortnite.ts new file mode 100644 index 000000000000..156f193aeb19 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/fortnite.ts @@ -0,0 +1,184 @@ +import { Properties } from '../properties'; +import { ConditionDefinition } from '.'; + +export type FortniteConditionPropsMap = { + //---------------------- + // Fortnite + //---------------------- + + // Game Flow + 'fortnite.game_started': undefined; + 'fortnite.deployed': undefined; + 'fortnite.storm_closing': undefined; + 'fortnite.game_ended': undefined; + + // Health / Shield + 'fortnite.low_health': undefined; + 'fortnite.has_shield': undefined; + 'fortnite.no_shield': undefined; + + // Win / Lose + 'fortnite.victory_royale': undefined; + 'fortnite.player_eliminated': undefined; + 'fortnite.player_knocked': undefined; + 'fortnite.defeat': undefined; + + // Enemy + 'fortnite.elimination': undefined; + 'fortnite.knocked': undefined; + 'fortnite.elimination_count': { elimination_count?: [number, number] }; + 'fortnite.players_remaining': { players_remaining?: [number, number] }; +}; + +export type FortniteConditionType = keyof FortniteConditionPropsMap; +export type FortniteConditionProps = FortniteConditionPropsMap[T]; + +export const FortniteConditions: { + [K in FortniteConditionType]: ConditionDefinition; +} = { + 'fortnite.game_started': { + group: 'fortnite', + name: 'game_started', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'fortnite.deployed': { + group: 'fortnite', + name: 'deployed', + label: 'Deployed', + evaluate: ({ state }) => state.pendingEvents.has('deploy'), + }, + + 'fortnite.game_ended': { + group: 'fortnite', + name: 'game_ended', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + // Health / Shield Conditions + 'fortnite.low_health': { + group: 'fortnite', + name: 'low_health', + label: 'Low Health', + evaluate: ({ state }) => { + const { health = 0 } = state; + return health > 0 && health < 50; + }, + }, + + 'fortnite.has_shield': { + group: 'fortnite', + name: 'has_shield', + label: 'Has Shield', + evaluate: ({ state }) => { + const { shield = 0 } = state; + return shield > 0; + }, + }, + + 'fortnite.no_shield': { + group: 'fortnite', + name: 'no_shield', + label: 'No Shield', + evaluate: ({ state }) => { + const { shield = 0 } = state; + return shield === 0; + }, + }, + + // Win / Lose Conditions + 'fortnite.victory_royale': { + group: 'fortnite', + name: 'victory_royale', + label: 'Victory Royale', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'fortnite.defeat': { + group: 'fortnite', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'fortnite.player_eliminated': { + group: 'fortnite', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'fortnite.player_knocked': { + group: 'fortnite', + name: 'player_knocked', + label: 'Player Knocked', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + 'fortnite.storm_closing': { + group: 'fortnite', + name: 'storm_closing', + label: 'Storm Closing', + evaluate: ({ state }) => state.pendingEvents.has('storm_shrinking'), + }, + + 'fortnite.elimination': { + group: 'fortnite', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'fortnite.knocked': { + group: 'fortnite', + name: 'knocked', + label: 'Enemy Knocked', + evaluate: ({ state }) => state.pendingEvents.has('knockout'), + }, + + 'fortnite.elimination_count': { + group: 'fortnite', + name: 'elimination_count', + label: 'Enemy Elimination Count', + properties: { + elimination_count: new Properties.SliderRange({ + label: '# of Eliminations', + min: 0, + max: 50, + default: [5, 5], + step: 1, + }), + }, + evaluate: ({ state, prevState, props }) => { + const [min, max] = props?.elimination_count ?? [5, 5]; + const { eliminations = 0 } = state; + const { eliminations: prevEliminations = 0 } = prevState; + return eliminations >= min && prevEliminations <= max; + }, + }, + + 'fortnite.players_remaining': { + group: 'fortnite', + name: 'players_remaining', + label: 'Players Remaining (coming soon)', + disabled: true, + properties: { + players_remaining: new Properties.SliderRange({ + label: '# of Players Remaining', + min: 1, + max: 100, + default: [1, 1], + step: 1, + }), + }, + evaluate: ({ state, props }) => { + const [min, max] = props?.players_remaining ?? [1, 1]; + const { playersRemaining = 0 } = state; + return playersRemaining >= min && playersRemaining <= max; + }, + }, +} as const; + +export default FortniteConditions; diff --git a/app/services/stream-avatar/engine/conditions/forzahorizon6.ts b/app/services/stream-avatar/engine/conditions/forzahorizon6.ts new file mode 100644 index 000000000000..f96be39a956f --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/forzahorizon6.ts @@ -0,0 +1,80 @@ +import { ConditionDefinition } from '.'; + +export type ForzaHorizon6ConditionPropsMap = { + //---------------------- + // Forza Horizon 6 + //---------------------- + + // Game Flow + 'forza_horizon_6.game_start': undefined; + 'forza_horizon_6.game_end': undefined; + + // Race Events + 'forza_horizon_6.position_change': undefined; + 'forza_horizon_6.lap_change': undefined; + + // Skill Events + 'forza_horizon_6.great_drift': undefined; + 'forza_horizon_6.great_air': undefined; + 'forza_horizon_6.great_skill_chain': undefined; +}; + +export type ForzaHorizon6ConditionType = keyof ForzaHorizon6ConditionPropsMap; +export type ForzaHorizon6ConditionProps< + T extends ForzaHorizon6ConditionType +> = ForzaHorizon6ConditionPropsMap[T]; + +export const ForzaHorizon6Conditions: { + [K in ForzaHorizon6ConditionType]: ConditionDefinition; +} = { + 'forza_horizon_6.game_start': { + group: 'forza_horizon_6', + name: 'game_start', + label: 'Race Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'forza_horizon_6.game_end': { + group: 'forza_horizon_6', + name: 'game_end', + label: 'Race Finished', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'forza_horizon_6.position_change': { + group: 'forza_horizon_6', + name: 'position_change', + label: 'Race Position Changed', + evaluate: ({ state }) => state.pendingEvents.has('position_change'), + }, + + 'forza_horizon_6.lap_change': { + group: 'forza_horizon_6', + name: 'lap_change', + label: 'New Lap Started', + evaluate: ({ state }) => state.pendingEvents.has('lap_change'), + }, + + 'forza_horizon_6.great_drift': { + group: 'forza_horizon_6', + name: 'great_drift', + label: 'Great Drift', + evaluate: ({ state }) => state.pendingEvents.has('great_drift'), + }, + + 'forza_horizon_6.great_air': { + group: 'forza_horizon_6', + name: 'great_air', + label: 'Great Air', + evaluate: ({ state }) => state.pendingEvents.has('great_air'), + }, + + 'forza_horizon_6.great_skill_chain': { + group: 'forza_horizon_6', + name: 'great_skill_chain', + label: 'Great Skill Chain', + evaluate: ({ state }) => state.pendingEvents.has('great_skill_chain'), + }, +} as const; + +export default ForzaHorizon6Conditions; diff --git a/app/services/stream-avatar/engine/conditions/index.ts b/app/services/stream-avatar/engine/conditions/index.ts new file mode 100644 index 000000000000..5bc9a5dfe0a5 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/index.ts @@ -0,0 +1,187 @@ +import type { PropertyInstance } from '../properties'; +import type { GameState } from '../game-state'; +import FortniteConditions, { FortniteConditionPropsMap } from './fortnite'; +import PubgConditions, { PubgConditionPropsMap } from './pubg'; +import ValorantConditions, { ValorantConditionPropsMap } from './valorant'; +import CounterStrike2Conditions, { CounterStrike2ConditionPropsMap } from './counterstrike2'; +import WarzoneConditions, { WarzoneConditionPropsMap } from './warzone'; +import ArcRaidersConditions, { ArcRaidersConditionPropsMap } from './arcraiders'; +import BlackOps6Conditions, { BlackOps6ConditionPropsMap } from './blackops6'; +import RocketLeagueConditions, { RocketLeagueConditionPropsMap } from './rocketleague'; +import MinecraftConditions, { MinecraftConditionPropsMap } from './minecraft'; +import ApexLegendsConditions, { ApexLegendsConditionPropsMap } from './apexlegends'; +import Battlefield6Conditions, { Battlefield6ConditionPropsMap } from './battlefield6'; +import DeadByDaylightConditions, { DeadByDaylightConditionPropsMap } from './deadbydaylight'; +import DeadlockConditions, { DeadlockConditionPropsMap } from './deadlock'; +import Dota2Conditions, { Dota2ConditionPropsMap } from './dota2'; +import LeagueOfLegendsConditions, { LeagueOfLegendsConditionPropsMap } from './leagueoflegends'; +import MarvelRivalsConditions, { MarvelRivalsConditionPropsMap } from './marvelrivals'; +import Overwatch2Conditions, { Overwatch2ConditionPropsMap } from './overwatch2'; +import RainbowSixSiegeConditions, { RainbowSixSiegeConditionPropsMap } from './rainbowsixsiege'; +import WarThunderConditions, { WarThunderConditionPropsMap } from './warthunder'; +import MarathonConditions, { MarathonConditionPropsMap } from './marathon'; +import F125Conditions, { F125ConditionPropsMap } from './f125'; +import EaSportsFc26Conditions, { EaSportsFc26ConditionPropsMap } from './easportsfc26'; +import Nba2k26Conditions, { Nba2k26ConditionPropsMap } from './nba2k26'; +import ForzaHorizon6Conditions, { ForzaHorizon6ConditionPropsMap } from './forzahorizon6'; +import EnshroudedConditions, { EnshroudedConditionPropsMap } from './enshrouded'; + +export type TEvaluatedCondition = { + condition: T; + status: boolean; +}; + +export type ConditionPropsMap = FortniteConditionPropsMap & + PubgConditionPropsMap & + ValorantConditionPropsMap & + CounterStrike2ConditionPropsMap & + WarzoneConditionPropsMap & + ArcRaidersConditionPropsMap & + BlackOps6ConditionPropsMap & + RocketLeagueConditionPropsMap & + MinecraftConditionPropsMap & + ApexLegendsConditionPropsMap & + Battlefield6ConditionPropsMap & + DeadByDaylightConditionPropsMap & + DeadlockConditionPropsMap & + Dota2ConditionPropsMap & + LeagueOfLegendsConditionPropsMap & + MarvelRivalsConditionPropsMap & + Overwatch2ConditionPropsMap & + RainbowSixSiegeConditionPropsMap & + WarThunderConditionPropsMap & + MarathonConditionPropsMap & + F125ConditionPropsMap & + EaSportsFc26ConditionPropsMap & + Nba2k26ConditionPropsMap & + ForzaHorizon6ConditionPropsMap & + EnshroudedConditionPropsMap; + +export type ConditionType = + | keyof FortniteConditionPropsMap + | keyof PubgConditionPropsMap + | keyof ValorantConditionPropsMap + | keyof CounterStrike2ConditionPropsMap + | keyof WarzoneConditionPropsMap + | keyof ArcRaidersConditionPropsMap + | keyof BlackOps6ConditionPropsMap + | keyof RocketLeagueConditionPropsMap + | keyof MinecraftConditionPropsMap + | keyof ApexLegendsConditionPropsMap + | keyof Battlefield6ConditionPropsMap + | keyof DeadByDaylightConditionPropsMap + | keyof DeadlockConditionPropsMap + | keyof Dota2ConditionPropsMap + | keyof LeagueOfLegendsConditionPropsMap + | keyof MarvelRivalsConditionPropsMap + | keyof Overwatch2ConditionPropsMap + | keyof RainbowSixSiegeConditionPropsMap + | keyof WarThunderConditionPropsMap + | keyof MarathonConditionPropsMap + | keyof F125ConditionPropsMap + | keyof EaSportsFc26ConditionPropsMap + | keyof Nba2k26ConditionPropsMap + | keyof ForzaHorizon6ConditionPropsMap + | keyof EnshroudedConditionPropsMap; + +export type ConditionProps = ConditionPropsMap[T]; + +export type ConditionDefinition = { + group: string; + name: string; + label: string; + disabled?: boolean; + properties?: Record; + evaluate: (args: { + state: GameState; + prevState: GameState; + props: ConditionPropsMap[K]; + }) => boolean; +}; + +const perGameConditions = { + ...FortniteConditions, + ...PubgConditions, + ...ValorantConditions, + ...CounterStrike2Conditions, + ...WarzoneConditions, + ...ArcRaidersConditions, + ...BlackOps6Conditions, + ...RocketLeagueConditions, + ...MinecraftConditions, + ...ApexLegendsConditions, + ...Battlefield6Conditions, + ...DeadByDaylightConditions, + ...DeadlockConditions, + ...Dota2Conditions, + ...LeagueOfLegendsConditions, + ...MarvelRivalsConditions, + ...Overwatch2Conditions, + ...RainbowSixSiegeConditions, + ...WarThunderConditions, + ...MarathonConditions, + ...F125Conditions, + ...EaSportsFc26Conditions, + ...Nba2k26Conditions, + ...ForzaHorizon6Conditions, + ...EnshroudedConditions, +} as const; + +export const Conditions: { [K in ConditionType]: ConditionDefinition } = perGameConditions; + +export const GAME_NAMES: Record = { + fortnite: 'Fortnite', + pubg: 'PUBG: Battlegrounds', + valorant: 'Valorant', + counter_strike_2: 'Counter-Strike 2', + black_ops_6: 'Call of Duty: Black Ops 6', + warzone: 'Call of Duty: Warzone', + rocket_league: 'Rocket League', + arc_raiders: 'Arc Raiders', + minecraft: 'Minecraft', + apex_legends: 'Apex Legends', + battlefield_6: 'Battlefield 6', + dead_by_daylight: 'Dead by Daylight', + deadlock: 'Deadlock', + dota_2: 'Dota 2', + league_of_legends: 'League of Legends', + marvel_rivals: 'Marvel Rivals', + overwatch_2: 'Overwatch 2', + rainbow_six_siege: 'Rainbow Six Siege', + war_thunder: 'War Thunder', + marathon: 'Marathon', + f1_25: 'F1 25', + ea_sports_fc_26: 'EA Sports FC 26', + nba_2k26: 'NBA 2K26', + forza_horizon_6: 'Forza Horizon 6', + enshrouded: 'Enshrouded', +}; + +export type TCondition = { + type: T; + props?: ConditionProps; +}; + +export class ConditionsManager { + static evaluate({ + condition, + state, + prevState, + }: { + condition: TCondition; + state: GameState; + prevState: GameState; + }) { + const def = Conditions[condition.type]; + if (!def) { + throw new Error(`Condition type "${condition.type}" not found`); + } + + const evaluateFn = (def as ConditionDefinition).evaluate; + return evaluateFn({ + state, + prevState, + props: condition.props as ConditionProps, + }); + } +} diff --git a/app/services/stream-avatar/engine/conditions/leagueoflegends.ts b/app/services/stream-avatar/engine/conditions/leagueoflegends.ts new file mode 100644 index 000000000000..1bb041b0e752 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/leagueoflegends.ts @@ -0,0 +1,106 @@ +import { ConditionDefinition } from '.'; + +export type LeagueOfLegendsConditionPropsMap = { + //---------------------- + // League of Legends + //---------------------- + + // Game Flow + 'league_of_legends.game_start': undefined; + 'league_of_legends.game_end': undefined; + + // Win / Lose + 'league_of_legends.victory': undefined; + 'league_of_legends.defeat': undefined; + + // Combat + 'league_of_legends.elimination': undefined; + 'league_of_legends.player_eliminated': undefined; + + // Objectives + 'league_of_legends.objective_ally': undefined; + 'league_of_legends.objective_enemy': undefined; + 'league_of_legends.enemy_turret_destroyed': undefined; + 'league_of_legends.ally_turret_destroyed': undefined; +}; + +export type LeagueOfLegendsConditionType = keyof LeagueOfLegendsConditionPropsMap; +export type LeagueOfLegendsConditionProps< + T extends LeagueOfLegendsConditionType +> = LeagueOfLegendsConditionPropsMap[T]; + +export const LeagueOfLegendsConditions: { + [K in LeagueOfLegendsConditionType]: ConditionDefinition; +} = { + 'league_of_legends.game_start': { + group: 'league_of_legends', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'league_of_legends.game_end': { + group: 'league_of_legends', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'league_of_legends.victory': { + group: 'league_of_legends', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'league_of_legends.defeat': { + group: 'league_of_legends', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'league_of_legends.elimination': { + group: 'league_of_legends', + name: 'elimination', + label: 'Champion Kill', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'league_of_legends.player_eliminated': { + group: 'league_of_legends', + name: 'player_eliminated', + label: 'Player Died', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'league_of_legends.objective_ally': { + group: 'league_of_legends', + name: 'objective_ally', + label: 'Ally Team Secured Objective', + evaluate: ({ state }) => state.pendingEvents.has('objective_ally'), + }, + + 'league_of_legends.objective_enemy': { + group: 'league_of_legends', + name: 'objective_enemy', + label: 'Enemy Team Secured Objective', + evaluate: ({ state }) => state.pendingEvents.has('objective_enemy'), + }, + + 'league_of_legends.enemy_turret_destroyed': { + group: 'league_of_legends', + name: 'enemy_turret_destroyed', + label: 'Enemy Turret Destroyed', + evaluate: ({ state }) => state.pendingEvents.has('enemy_turret_destroyed'), + }, + + 'league_of_legends.ally_turret_destroyed': { + group: 'league_of_legends', + name: 'ally_turret_destroyed', + label: 'Ally Turret Destroyed', + evaluate: ({ state }) => state.pendingEvents.has('ally_turret_destroyed'), + }, +} as const; + +export default LeagueOfLegendsConditions; diff --git a/app/services/stream-avatar/engine/conditions/marathon.ts b/app/services/stream-avatar/engine/conditions/marathon.ts new file mode 100644 index 000000000000..c568102fd761 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/marathon.ts @@ -0,0 +1,88 @@ +import { ConditionDefinition } from '.'; + +export type MarathonConditionPropsMap = { + //---------------------- + // Marathon + //---------------------- + + // Game Flow + 'marathon.game_start': undefined; + 'marathon.game_end': undefined; + + // Win / Lose + 'marathon.victory': undefined; + 'marathon.defeat': undefined; + + // Player + 'marathon.player_knocked': undefined; + 'marathon.player_eliminated': undefined; + + // Enemy + 'marathon.elimination': undefined; + 'marathon.knockout': undefined; +}; + +export type MarathonConditionType = keyof MarathonConditionPropsMap; +export type MarathonConditionProps = MarathonConditionPropsMap[T]; + +export const MarathonConditions: { + [K in MarathonConditionType]: ConditionDefinition; +} = { + 'marathon.game_start': { + group: 'marathon', + name: 'game_start', + label: 'Match Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'marathon.game_end': { + group: 'marathon', + name: 'game_end', + label: 'Match Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'marathon.victory': { + group: 'marathon', + name: 'victory', + label: 'Exfiltrated (Victory)', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'marathon.defeat': { + group: 'marathon', + name: 'defeat', + label: 'Eliminated (Defeat)', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'marathon.player_knocked': { + group: 'marathon', + name: 'player_knocked', + label: 'Player Knocked', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + 'marathon.player_eliminated': { + group: 'marathon', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'marathon.elimination': { + group: 'marathon', + name: 'elimination', + label: 'Runner Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'marathon.knockout': { + group: 'marathon', + name: 'knockout', + label: 'Runner Knocked', + evaluate: ({ state }) => state.pendingEvents.has('knockout'), + }, +} as const; + +export default MarathonConditions; diff --git a/app/services/stream-avatar/engine/conditions/marvelrivals.ts b/app/services/stream-avatar/engine/conditions/marvelrivals.ts new file mode 100644 index 000000000000..b21230a1ae98 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/marvelrivals.ts @@ -0,0 +1,64 @@ +import { ConditionDefinition } from '.'; + +export type MarvelRivalsConditionPropsMap = { + //---------------------- + // Marvel Rivals + //---------------------- + + // Game Flow + 'marvel_rivals.game_end': undefined; + + // Win / Lose + 'marvel_rivals.victory': undefined; + 'marvel_rivals.defeat': undefined; + + // Combat + 'marvel_rivals.elimination': undefined; + 'marvel_rivals.player_eliminated': undefined; +}; + +export type MarvelRivalsConditionType = keyof MarvelRivalsConditionPropsMap; +export type MarvelRivalsConditionProps< + T extends MarvelRivalsConditionType +> = MarvelRivalsConditionPropsMap[T]; + +export const MarvelRivalsConditions: { + [K in MarvelRivalsConditionType]: ConditionDefinition; +} = { + 'marvel_rivals.game_end': { + group: 'marvel_rivals', + name: 'game_end', + label: 'Match Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'marvel_rivals.victory': { + group: 'marvel_rivals', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'marvel_rivals.defeat': { + group: 'marvel_rivals', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'marvel_rivals.elimination': { + group: 'marvel_rivals', + name: 'elimination', + label: 'Hero Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'marvel_rivals.player_eliminated': { + group: 'marvel_rivals', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, +} as const; + +export default MarvelRivalsConditions; diff --git a/app/services/stream-avatar/engine/conditions/minecraft.ts b/app/services/stream-avatar/engine/conditions/minecraft.ts new file mode 100644 index 000000000000..5c9a117d8a00 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/minecraft.ts @@ -0,0 +1,100 @@ +import { ConditionDefinition } from '.'; + +export type MinecraftConditionPropsMap = { + //---------------------- + // Minecraft + //---------------------- + + // Boss Events + 'minecraft.ender_dragon_spawned': undefined; + 'minecraft.boss_killed': undefined; + 'minecraft.wither_spawned': undefined; + + // Progression + 'minecraft.advancement_made': undefined; + 'minecraft.first_diamond': undefined; + 'minecraft.nether_entered': undefined; + + // Player + 'minecraft.player_eliminated': undefined; + 'minecraft.low_health': undefined; + 'minecraft.totem_of_undying_used': undefined; +}; + +export type MinecraftConditionType = keyof MinecraftConditionPropsMap; +export type MinecraftConditionProps< + T extends MinecraftConditionType +> = MinecraftConditionPropsMap[T]; + +export const MinecraftConditions: { + [K in MinecraftConditionType]: ConditionDefinition; +} = { + 'minecraft.ender_dragon_spawned': { + group: 'minecraft', + name: 'ender_dragon_spawned', + label: 'Ender Dragon Spawned', + evaluate: ({ state }) => state.pendingEvents.has('ender_dragon_spawned'), + }, + + 'minecraft.boss_killed': { + group: 'minecraft', + name: 'boss_killed', + label: 'Boss Killed', + evaluate: ({ state }) => state.pendingEvents.has('boss_killed'), + }, + + 'minecraft.wither_spawned': { + group: 'minecraft', + name: 'wither_spawned', + label: 'Wither Spawned', + evaluate: ({ state }) => state.pendingEvents.has('wither_spawned'), + }, + + 'minecraft.advancement_made': { + group: 'minecraft', + name: 'advancement_made', + label: 'Advancement Made', + evaluate: ({ state }) => state.pendingEvents.has('advancement_made'), + }, + + 'minecraft.first_diamond': { + group: 'minecraft', + name: 'first_diamond', + label: 'First Diamond', + evaluate: ({ state }) => state.pendingEvents.has('first_diamond'), + }, + + 'minecraft.nether_entered': { + group: 'minecraft', + name: 'nether_entered', + label: 'Nether Entered', + evaluate: ({ state }) => state.pendingEvents.has('nether_entered'), + }, + + 'minecraft.player_eliminated': { + group: 'minecraft', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'minecraft.low_health': { + group: 'minecraft', + name: 'low_health', + label: 'Low Health', + evaluate: ({ state, prevState }) => { + const { health = 100 } = state; + const { health: prevHealth = 100 } = prevState; + return health < 50 && prevHealth >= 50; + }, + }, + + 'minecraft.totem_of_undying_used': { + group: 'minecraft', + name: 'totem_of_undying_used', + label: 'Totem of Undying Used', + evaluate: ({ state }) => state.pendingEvents.has('totem_of_undying_used'), + }, +} as const; + +export default MinecraftConditions; diff --git a/app/services/stream-avatar/engine/conditions/nba2k26.ts b/app/services/stream-avatar/engine/conditions/nba2k26.ts new file mode 100644 index 000000000000..06a448027d3e --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/nba2k26.ts @@ -0,0 +1,52 @@ +import { ConditionDefinition } from '.'; + +export type Nba2k26ConditionPropsMap = { + //---------------------- + // NBA 2K26 + //---------------------- + + // Game Flow + 'nba_2k26.game_start': undefined; + 'nba_2k26.game_end': undefined; + + // Match Events + 'nba_2k26.goal': undefined; + 'nba_2k26.halftime': undefined; +}; + +export type Nba2k26ConditionType = keyof Nba2k26ConditionPropsMap; +export type Nba2k26ConditionProps = Nba2k26ConditionPropsMap[T]; + +export const Nba2k26Conditions: { + [K in Nba2k26ConditionType]: ConditionDefinition; +} = { + 'nba_2k26.game_start': { + group: 'nba_2k26', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'nba_2k26.game_end': { + group: 'nba_2k26', + name: 'game_end', + label: 'Game Ended (Final)', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'nba_2k26.goal': { + group: 'nba_2k26', + name: 'goal', + label: 'Basket Scored', + evaluate: ({ state }) => state.pendingEvents.has('goal'), + }, + + 'nba_2k26.halftime': { + group: 'nba_2k26', + name: 'halftime', + label: 'Halftime', + evaluate: ({ state }) => state.pendingEvents.has('halftime'), + }, +} as const; + +export default Nba2k26Conditions; diff --git a/app/services/stream-avatar/engine/conditions/overwatch2.ts b/app/services/stream-avatar/engine/conditions/overwatch2.ts new file mode 100644 index 000000000000..0754fbf46422 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/overwatch2.ts @@ -0,0 +1,72 @@ +import { ConditionDefinition } from '.'; + +export type Overwatch2ConditionPropsMap = { + //---------------------- + // Overwatch 2 + //---------------------- + + // Game Flow + 'overwatch_2.round_start': undefined; + 'overwatch_2.round_end': undefined; + + // Win / Lose + 'overwatch_2.victory': undefined; + 'overwatch_2.defeat': undefined; + + // Combat + 'overwatch_2.elimination': undefined; + 'overwatch_2.player_eliminated': undefined; +}; + +export type Overwatch2ConditionType = keyof Overwatch2ConditionPropsMap; +export type Overwatch2ConditionProps< + T extends Overwatch2ConditionType +> = Overwatch2ConditionPropsMap[T]; + +export const Overwatch2Conditions: { + [K in Overwatch2ConditionType]: ConditionDefinition; +} = { + 'overwatch_2.round_start': { + group: 'overwatch_2', + name: 'round_start', + label: 'Round Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'overwatch_2.round_end': { + group: 'overwatch_2', + name: 'round_end', + label: 'Round Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'overwatch_2.victory': { + group: 'overwatch_2', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'overwatch_2.defeat': { + group: 'overwatch_2', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'overwatch_2.elimination': { + group: 'overwatch_2', + name: 'elimination', + label: 'Elimination', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'overwatch_2.player_eliminated': { + group: 'overwatch_2', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, +} as const; + +export default Overwatch2Conditions; diff --git a/app/services/stream-avatar/engine/conditions/pubg.ts b/app/services/stream-avatar/engine/conditions/pubg.ts new file mode 100644 index 000000000000..a371f88cd8d2 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/pubg.ts @@ -0,0 +1,150 @@ +import { Properties } from '../properties'; +import { ConditionDefinition } from '.'; + +export type PubgConditionPropsMap = { + //---------------------- + // PUBG + //---------------------- + + // Game Flow + 'pubg.game_started': undefined; + 'pubg.deployed': undefined; + 'pubg.storm_closing': undefined; + 'pubg.game_ended': undefined; + + // Player + 'pubg.victory': undefined; + 'pubg.player_eliminated': undefined; + 'pubg.player_knocked': undefined; + 'pubg.defeat': undefined; + + // Enemy + 'pubg.elimination': undefined; + 'pubg.knocked': undefined; + 'pubg.elimination_count': { elimination_count?: [number, number] }; + 'pubg.players_remaining': { players_remaining?: [number, number] }; +}; + +export type PubgConditionType = keyof PubgConditionPropsMap; +export type PubgConditionProps = PubgConditionPropsMap[T]; + +export const PubgConditions: { + [K in PubgConditionType]: ConditionDefinition; +} = { + // Game Flow + 'pubg.game_started': { + group: 'pubg', + name: 'game_started', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'pubg.deployed': { + group: 'pubg', + name: 'deployed', + label: 'Deployed', + evaluate: ({ state }) => state.pendingEvents.has('deploy'), + }, + + 'pubg.storm_closing': { + group: 'pubg', + name: 'storm_closing', + label: 'Storm Closing', + evaluate: ({ state }) => state.pendingEvents.has('storm_shrinking'), + }, + + 'pubg.game_ended': { + group: 'pubg', + name: 'game_ended', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + // Player + 'pubg.victory': { + group: 'pubg', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'pubg.defeat': { + group: 'pubg', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'pubg.player_eliminated': { + group: 'pubg', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'pubg.player_knocked': { + group: 'pubg', + name: 'player_knocked', + label: 'Player Knocked', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + // Enemy + 'pubg.elimination': { + group: 'pubg', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'pubg.knocked': { + group: 'pubg', + name: 'knocked', + label: 'Enemy Knocked', + evaluate: ({ state }) => state.pendingEvents.has('knockout'), + }, + + 'pubg.elimination_count': { + group: 'pubg', + name: 'elimination_count', + label: 'Enemy Elimination Count', + properties: { + elimination_count: new Properties.SliderRange({ + label: '# of Eliminations', + min: 0, + max: 50, + default: [5, 5], + step: 1, + }), + }, + evaluate: ({ state, prevState, props }) => { + const [min, max] = props?.elimination_count ?? [5, 5]; + const { eliminations = 0 } = state; + const { eliminations: prevEliminations = 0 } = prevState; + return eliminations >= min && prevEliminations <= max; + }, + }, + + 'pubg.players_remaining': { + group: 'pubg', + name: 'players_remaining', + label: 'Players Remaining (coming soon)', + disabled: true, + properties: { + players_remaining: new Properties.SliderRange({ + label: '# of Players Remaining', + min: 1, + max: 100, + default: [1, 1], + step: 1, + }), + }, + evaluate: ({ state, props }) => { + const [min, max] = props?.players_remaining ?? [1, 1]; + const { playersRemaining = 0 } = state; + return playersRemaining >= min && playersRemaining <= max; + }, + }, +} as const; + +export default PubgConditions; diff --git a/app/services/stream-avatar/engine/conditions/rainbowsixsiege.ts b/app/services/stream-avatar/engine/conditions/rainbowsixsiege.ts new file mode 100644 index 000000000000..15ea2724aea5 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/rainbowsixsiege.ts @@ -0,0 +1,80 @@ +import { ConditionDefinition } from '.'; + +export type RainbowSixSiegeConditionPropsMap = { + //---------------------- + // Rainbow Six Siege + //---------------------- + + // Game Flow + 'rainbow_six_siege.round_start': undefined; + 'rainbow_six_siege.action_phase': undefined; + 'rainbow_six_siege.round_end': undefined; + + // Win / Lose + 'rainbow_six_siege.victory': undefined; + 'rainbow_six_siege.defeat': undefined; + + // Combat + 'rainbow_six_siege.elimination': undefined; + 'rainbow_six_siege.player_eliminated': undefined; +}; + +export type RainbowSixSiegeConditionType = keyof RainbowSixSiegeConditionPropsMap; +export type RainbowSixSiegeConditionProps< + T extends RainbowSixSiegeConditionType +> = RainbowSixSiegeConditionPropsMap[T]; + +export const RainbowSixSiegeConditions: { + [K in RainbowSixSiegeConditionType]: ConditionDefinition; +} = { + 'rainbow_six_siege.round_start': { + group: 'rainbow_six_siege', + name: 'round_start', + label: 'Round Started (Preparation Phase)', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'rainbow_six_siege.action_phase': { + group: 'rainbow_six_siege', + name: 'action_phase', + label: 'Action Phase Started', + evaluate: ({ state }) => state.pendingEvents.has('action_phase'), + }, + + 'rainbow_six_siege.round_end': { + group: 'rainbow_six_siege', + name: 'round_end', + label: 'Round Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'rainbow_six_siege.victory': { + group: 'rainbow_six_siege', + name: 'victory', + label: 'Round Won', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'rainbow_six_siege.defeat': { + group: 'rainbow_six_siege', + name: 'defeat', + label: 'Round Lost', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'rainbow_six_siege.elimination': { + group: 'rainbow_six_siege', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'rainbow_six_siege.player_eliminated': { + group: 'rainbow_six_siege', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, +} as const; + +export default RainbowSixSiegeConditions; diff --git a/app/services/stream-avatar/engine/conditions/rocketleague.ts b/app/services/stream-avatar/engine/conditions/rocketleague.ts new file mode 100644 index 000000000000..36951193da49 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/rocketleague.ts @@ -0,0 +1,72 @@ +import { ConditionDefinition } from '.'; + +export type RocketLeagueConditionPropsMap = { + //---------------------- + // Rocket League + //---------------------- + + // Game Flow + 'rocket_league.game_start': undefined; + 'rocket_league.game_end': undefined; + + // Scoring + 'rocket_league.team_scored': undefined; + 'rocket_league.opponent_scored': undefined; + + // Win / Lose + 'rocket_league.victory': undefined; + 'rocket_league.defeat': undefined; +}; + +export type RocketLeagueConditionType = keyof RocketLeagueConditionPropsMap; +export type RocketLeagueConditionProps< + T extends RocketLeagueConditionType +> = RocketLeagueConditionPropsMap[T]; + +export const RocketLeagueConditions: { + [K in RocketLeagueConditionType]: ConditionDefinition; +} = { + 'rocket_league.game_start': { + group: 'rocket_league', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'rocket_league.game_end': { + group: 'rocket_league', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'rocket_league.team_scored': { + group: 'rocket_league', + name: 'team_scored', + label: 'Team Scored', + evaluate: ({ state }) => state.pendingEvents.has('team_scored'), + }, + + 'rocket_league.opponent_scored': { + group: 'rocket_league', + name: 'opponent_scored', + label: 'Opponent Scored', + evaluate: ({ state }) => state.pendingEvents.has('opponent_scored'), + }, + + 'rocket_league.victory': { + group: 'rocket_league', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'rocket_league.defeat': { + group: 'rocket_league', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, +} as const; + +export default RocketLeagueConditions; diff --git a/app/services/stream-avatar/engine/conditions/valorant.ts b/app/services/stream-avatar/engine/conditions/valorant.ts new file mode 100644 index 000000000000..019c2b61c33a --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/valorant.ts @@ -0,0 +1,102 @@ +import { Properties } from '../properties'; +import { ConditionDefinition } from '.'; + +export type ValorantConditionPropsMap = { + //---------------------- + // Valorant + //---------------------- + + // Game Flow + 'valorant.round_started': undefined; + + // Health Shield + 'valorant.low_health': undefined; + + // Player + 'valorant.victory': undefined; + 'valorant.player_eliminated': undefined; + 'valorant.defeat': undefined; + + // Enemy + 'valorant.elimination': undefined; + 'valorant.elimination_count': { elimination_count?: [number, number] }; +}; + +export type ValorantConditionType = keyof ValorantConditionPropsMap; +export type ValorantConditionProps = ValorantConditionPropsMap[T]; + +export const ValorantConditions: { + [K in ValorantConditionType]: ConditionDefinition; +} = { + // Game Flow + 'valorant.round_started': { + group: 'valorant', + name: 'round_started', + label: 'Round Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + // Health / Shield Conditions + 'valorant.low_health': { + group: 'valorant', + name: 'low_health', + label: 'Low Health', + evaluate: ({ state }) => { + const { health = 0 } = state; + return health > 0 && health < 50; + }, + }, + + // Player + 'valorant.victory': { + group: 'valorant', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'valorant.defeat': { + group: 'valorant', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'valorant.player_eliminated': { + group: 'valorant', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + // Enemy + 'valorant.elimination': { + group: 'valorant', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'valorant.elimination_count': { + group: 'valorant', + name: 'elimination_count', + label: 'Enemy Elimination Count', + properties: { + elimination_count: new Properties.SliderRange({ + label: '# of Eliminations', + min: 0, + max: 50, + default: [5, 5], + step: 1, + }), + }, + evaluate: ({ state, prevState, props }) => { + const [min, max] = props?.elimination_count ?? [5, 5]; + const { eliminations = 0 } = state; + const { eliminations: prevEliminations = 0 } = prevState; + return eliminations >= min && prevEliminations <= max; + }, + }, +} as const; + +export default ValorantConditions; diff --git a/app/services/stream-avatar/engine/conditions/warthunder.ts b/app/services/stream-avatar/engine/conditions/warthunder.ts new file mode 100644 index 000000000000..e23a568d4a1b --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/warthunder.ts @@ -0,0 +1,28 @@ +import { ConditionDefinition } from '.'; + +export type WarThunderConditionPropsMap = { + //---------------------- + // War Thunder + //---------------------- + + // Combat + 'war_thunder.elimination': undefined; +}; + +export type WarThunderConditionType = keyof WarThunderConditionPropsMap; +export type WarThunderConditionProps< + T extends WarThunderConditionType +> = WarThunderConditionPropsMap[T]; + +export const WarThunderConditions: { + [K in WarThunderConditionType]: ConditionDefinition; +} = { + 'war_thunder.elimination': { + group: 'war_thunder', + name: 'elimination', + label: 'Target Destroyed', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, +} as const; + +export default WarThunderConditions; diff --git a/app/services/stream-avatar/engine/conditions/warzone.ts b/app/services/stream-avatar/engine/conditions/warzone.ts new file mode 100644 index 000000000000..49b201091d9c --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/warzone.ts @@ -0,0 +1,155 @@ +import { Properties } from '../properties'; +import { ConditionDefinition } from '.'; + +export type WarzoneConditionPropsMap = { + //---------------------- + // Warzone + //---------------------- + + // Game Flow + 'warzone.deploy': undefined; + 'warzone.gulag_start': undefined; + 'warzone.gulag_end': undefined; + 'warzone.spectating': undefined; + 'warzone.redeploying': undefined; + + // Player + 'warzone.victory': undefined; + 'warzone.player_knocked': undefined; + 'warzone.player_eliminated': undefined; + 'warzone.defeat': undefined; + + // Enemy + 'warzone.elimination': undefined; + 'warzone.knockout': undefined; + 'warzone.elimination_count': { elimination_count?: [number, number] }; + 'warzone.players_remaining': { players_remaining?: [number, number] }; +}; + +export type WarzoneConditionType = keyof WarzoneConditionPropsMap; +export type WarzoneConditionProps = WarzoneConditionPropsMap[T]; + +export const WarzoneConditions: { + [K in WarzoneConditionType]: ConditionDefinition; +} = { + 'warzone.deploy': { + group: 'warzone', + name: 'deploy', + label: 'Deploy', + evaluate: ({ state }) => state.pendingEvents.has('deploy'), + }, + + 'warzone.elimination': { + group: 'warzone', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'warzone.knockout': { + group: 'warzone', + name: 'knockout', + label: 'Enemy Downed', + evaluate: ({ state }) => state.pendingEvents.has('knockout'), + }, + + 'warzone.player_knocked': { + group: 'warzone', + name: 'player_knocked', + label: 'Player Downed', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + 'warzone.player_eliminated': { + group: 'warzone', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'warzone.victory': { + group: 'warzone', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'warzone.defeat': { + group: 'warzone', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'warzone.gulag_start': { + group: 'warzone', + name: 'gulag_start', + label: 'Gulag Started', + evaluate: ({ state }) => state.pendingEvents.has('gulag_start'), + }, + + 'warzone.gulag_end': { + group: 'warzone', + name: 'gulag_end', + label: 'Gulag Ended', + evaluate: ({ state }) => state.pendingEvents.has('gulag_end'), + }, + + 'warzone.spectating': { + group: 'warzone', + name: 'spectating', + label: 'Spectating', + evaluate: ({ state }) => state.pendingEvents.has('spectating'), + }, + + 'warzone.redeploying': { + group: 'warzone', + name: 'redeploying', + label: 'Redeploying', + evaluate: ({ state }) => state.pendingEvents.has('redeploying'), + }, + + 'warzone.elimination_count': { + group: 'warzone', + name: 'elimination_count', + label: 'Enemy Elimination Count', + properties: { + elimination_count: new Properties.SliderRange({ + label: '# of Eliminations', + min: 0, + max: 50, + default: [5, 5], + step: 1, + }), + }, + evaluate: ({ state, prevState, props }) => { + const [min, max] = props?.elimination_count ?? [5, 5]; + const { eliminations = 0 } = state; + const { eliminations: prevEliminations = 0 } = prevState; + return eliminations >= min && prevEliminations <= max; + }, + }, + + 'warzone.players_remaining': { + group: 'warzone', + name: 'players_remaining', + label: 'Players Remaining (coming soon)', + disabled: true, + properties: { + players_remaining: new Properties.SliderRange({ + label: '# of Players Remaining', + min: 1, + max: 150, + default: [1, 1], + step: 1, + }), + }, + evaluate: ({ state, props }) => { + const [min, max] = props?.players_remaining ?? [1, 1]; + const { playersRemaining = 0 } = state; + return playersRemaining >= min && playersRemaining <= max; + }, + }, +} as const; + +export default WarzoneConditions; diff --git a/app/services/stream-avatar/engine/game-state.ts b/app/services/stream-avatar/engine/game-state.ts new file mode 100644 index 000000000000..a91bdf1ee4b2 --- /dev/null +++ b/app/services/stream-avatar/engine/game-state.ts @@ -0,0 +1,30 @@ +export interface GameState { + state: string; + frame?: string; + + health: number; + shield: number; + + eliminations: number; + totalEliminations: number; + + teamScore: number; + opponentScore: number; + + playersRemaining: number; + + pendingEvents: ReadonlySet; +} + +export const defaultGameState: GameState = { + state: 'stopped', + frame: undefined, + health: 100, + shield: 100, + eliminations: 0, + totalEliminations: 0, + teamScore: 0, + opponentScore: 0, + playersRemaining: 0, + pendingEvents: new Set(), +}; diff --git a/app/services/stream-avatar/engine/instructions/apexlegends.ts b/app/services/stream-avatar/engine/instructions/apexlegends.ts new file mode 100644 index 000000000000..66062d32a80e --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/apexlegends.ts @@ -0,0 +1,18 @@ +import { ApexLegendsConditionPropsMap } from "../conditions/apexlegends"; +type ApexLegendsInstructionType = keyof ApexLegendsConditionPropsMap; + +export const ApexLegendsInstructions: Record = { + "apex_legends.game_start": "React to the match starting. 8 words max.", + "apex_legends.deploy": "React to {player} jumping from the dropship. 8 words max.", + "apex_legends.storm_shrinking": "Warn about the ring closing. 8 words max.", + "apex_legends.game_end": "React to the match ending. 8 words max.", + "apex_legends.player_knocked": "React to {player} getting knocked. 8 words max.", + "apex_legends.player_revived": "React to {player} being revived. 8 words max.", + "apex_legends.player_eliminated": "React to {player} being eliminated. 8 words max.", + "apex_legends.victory": "Celebrate {player}'s squad being Champion! 8 words max.", + "apex_legends.defeat": "React to {player}'s squad being eliminated. 8 words max.", + "apex_legends.elimination": "React to an enemy being eliminated. 8 words max.", + "apex_legends.knockout": "React to {player} knocking an enemy. 8 words max.", +}; + +export default ApexLegendsInstructions; diff --git a/app/services/stream-avatar/engine/instructions/arcraiders.ts b/app/services/stream-avatar/engine/instructions/arcraiders.ts new file mode 100644 index 000000000000..45eda2ed9a38 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/arcraiders.ts @@ -0,0 +1,18 @@ +import { ArcRaidersConditionPropsMap } from "../conditions/arcraiders"; + +type ArcRaidersInstructionType = keyof ArcRaidersConditionPropsMap; + +export const ArcRaidersInstructions: Record = + { + "arc_raiders.game_start": "React to the raid starting. 8 words max.", + "arc_raiders.game_end": "React to the raid ending. 8 words max.", + "arc_raiders.victory": "Celebrate {player}'s victory! 8 words max.", + "arc_raiders.defeat": "React to {player}'s defeat. 8 words max.", + "arc_raiders.player_knocked": "React to {player} going down. 8 words max.", + "arc_raiders.player_eliminated": "React to {player} being eliminated. 8 words max.", + "arc_raiders.enemy_spotted": "React to an enemy being spotted. 8 words max.", + "arc_raiders.enemy_detected": "React to an enemy detected nearby. 8 words max.", + "arc_raiders.interesting_moment": "React to an interesting moment. 8 words max.", + }; + +export default ArcRaidersInstructions; diff --git a/app/services/stream-avatar/engine/instructions/battlefield6.ts b/app/services/stream-avatar/engine/instructions/battlefield6.ts new file mode 100644 index 000000000000..75ea1836b05e --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/battlefield6.ts @@ -0,0 +1,13 @@ +import { Battlefield6ConditionPropsMap } from "../conditions/battlefield6"; +type Battlefield6InstructionType = keyof Battlefield6ConditionPropsMap; + +export const Battlefield6Instructions: Record = { + "battlefield_6.game_start": "React to the battle starting. 8 words max.", + "battlefield_6.game_end": "React to the match ending. 8 words max.", + "battlefield_6.victory": "Celebrate {player}'s team winning! 8 words max.", + "battlefield_6.defeat": "React to {player}'s team losing. 8 words max.", + "battlefield_6.elimination": "React to an enemy being eliminated. 8 words max.", + "battlefield_6.player_eliminated": "React to {player} going down. 8 words max.", +}; + +export default Battlefield6Instructions; diff --git a/app/services/stream-avatar/engine/instructions/blackops6.ts b/app/services/stream-avatar/engine/instructions/blackops6.ts new file mode 100644 index 000000000000..a69eaf22c3d7 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/blackops6.ts @@ -0,0 +1,12 @@ +import { BlackOps6ConditionPropsMap } from "../conditions/blackops6"; + +type BlackOps6InstructionType = keyof BlackOps6ConditionPropsMap; + +export const BlackOps6Instructions: Record = { + "black_ops_6.elimination": "React to an enemy being eliminated. 8 words max.", + "black_ops_6.victory": "Celebrate {player}'s victory! 8 words max.", + "black_ops_6.defeat": "React to {player}'s defeat. 8 words max.", + "black_ops_6.spectating": "React to {player} now spectating. 8 words max.", +}; + +export default BlackOps6Instructions; diff --git a/app/services/stream-avatar/engine/instructions/counterstrike2.ts b/app/services/stream-avatar/engine/instructions/counterstrike2.ts new file mode 100644 index 000000000000..8a00acafb48d --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/counterstrike2.ts @@ -0,0 +1,23 @@ +import { CounterStrike2ConditionPropsMap } from "../conditions/counterstrike2"; +type CounterStrike2InstructionType = keyof CounterStrike2ConditionPropsMap; + +export const CounterStrike2Instructions: Record< + CounterStrike2InstructionType, + string +> = { + "counter_strike_2.round_started": "React to the round starting. 8 words max.", + "counter_strike_2.first_half": "React to the first half starting. 8 words max.", + "counter_strike_2.second_half": "React to the second half starting. 8 words max.", + "counter_strike_2.round_won": "React to {player}'s team winning the round. 8 words max.", + "counter_strike_2.round_lost": "React to {player}'s team losing the round. 8 words max.", + "counter_strike_2.game_ended": "React to the match ending. 8 words max.", + "counter_strike_2.low_health": "Panic about {player}'s low health. 8 words max.", + "counter_strike_2.victory": "Celebrate {player}'s team winning the match. 8 words max.", + "counter_strike_2.player_eliminated": "React to {player} being eliminated. 8 words max.", + "counter_strike_2.defeat": "React to {player}'s team losing the match. 8 words max.", + "counter_strike_2.elimination": "React to an enemy being eliminated. 8 words max.", + "counter_strike_2.elimination_count": + "React to {player} reaching {elimination_count} kills. 8 words max.", +}; + +export default CounterStrike2Instructions; diff --git a/app/services/stream-avatar/engine/instructions/deadbydaylight.ts b/app/services/stream-avatar/engine/instructions/deadbydaylight.ts new file mode 100644 index 000000000000..172daddb6f18 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/deadbydaylight.ts @@ -0,0 +1,14 @@ +import { DeadByDaylightConditionPropsMap } from "../conditions/deadbydaylight"; +type DeadByDaylightInstructionType = keyof DeadByDaylightConditionPropsMap; + +export const DeadByDaylightInstructions: Record = { + "dead_by_daylight.game_start": "React to the trial beginning. 8 words max.", + "dead_by_daylight.game_end": "React to the trial ending. 8 words max.", + "dead_by_daylight.victory": "React to the match ending in victory. 8 words max.", + "dead_by_daylight.player_eliminated": "React to {player} being sacrificed. 8 words max.", + "dead_by_daylight.elimination": "React to a survivor being sacrificed. 8 words max.", + "dead_by_daylight.hooked_survivor": "React to a survivor being hooked. 8 words max.", + "dead_by_daylight.escaped": "React to a survivor escaping. 8 words max.", +}; + +export default DeadByDaylightInstructions; diff --git a/app/services/stream-avatar/engine/instructions/deadlock.ts b/app/services/stream-avatar/engine/instructions/deadlock.ts new file mode 100644 index 000000000000..f3b7094807a9 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/deadlock.ts @@ -0,0 +1,13 @@ +import { DeadlockConditionPropsMap } from "../conditions/deadlock"; +type DeadlockInstructionType = keyof DeadlockConditionPropsMap; + +export const DeadlockInstructions: Record = { + "deadlock.game_start": "React to the match starting. 8 words max.", + "deadlock.game_end": "React to the match ending. 8 words max.", + "deadlock.victory": "Celebrate {player}'s team winning! 8 words max.", + "deadlock.defeat": "React to {player}'s team losing. 8 words max.", + "deadlock.elimination": "React to an enemy hero being eliminated. 8 words max.", + "deadlock.player_eliminated": "React to {player} being eliminated. 8 words max.", +}; + +export default DeadlockInstructions; diff --git a/app/services/stream-avatar/engine/instructions/dota2.ts b/app/services/stream-avatar/engine/instructions/dota2.ts new file mode 100644 index 000000000000..29129b84f32b --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/dota2.ts @@ -0,0 +1,15 @@ +import { Dota2ConditionPropsMap } from "../conditions/dota2"; +type Dota2InstructionType = keyof Dota2ConditionPropsMap; + +export const Dota2Instructions: Record = { + "dota_2.game_start": "React to the match starting. 8 words max.", + "dota_2.game_end": "React to the match ending. 8 words max.", + "dota_2.victory": "Celebrate {player}'s team destroying the Ancient! 8 words max.", + "dota_2.defeat": "React to {player}'s team losing. 8 words max.", + "dota_2.elimination": "React to a hero kill. 8 words max.", + "dota_2.player_eliminated": "React to {player}'s hero dying. 8 words max.", + "dota_2.tower_destroyed": "React to a tower being destroyed. 8 words max.", + "dota_2.glyph_used": "React to the Glyph being activated. 8 words max.", +}; + +export default Dota2Instructions; diff --git a/app/services/stream-avatar/engine/instructions/easportsfc26.ts b/app/services/stream-avatar/engine/instructions/easportsfc26.ts new file mode 100644 index 000000000000..3ad1787085a2 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/easportsfc26.ts @@ -0,0 +1,13 @@ +import { EaSportsFc26ConditionPropsMap } from "../conditions/easportsfc26"; +type EaSportsFc26InstructionType = keyof EaSportsFc26ConditionPropsMap; + +export const EaSportsFc26Instructions: Record = { + "ea_sports_fc_26.game_start": "React to the match kicking off. 8 words max.", + "ea_sports_fc_26.game_end": "React to the final whistle. 8 words max.", + "ea_sports_fc_26.goal": "React to a goal being scored! 8 words max.", + "ea_sports_fc_26.set_piece": "React to a set piece opportunity. 8 words max.", + "ea_sports_fc_26.halftime": "React to halftime. 8 words max.", + "ea_sports_fc_26.fulltime": "React to the full time whistle. 8 words max.", +}; + +export default EaSportsFc26Instructions; diff --git a/app/services/stream-avatar/engine/instructions/enshrouded.ts b/app/services/stream-avatar/engine/instructions/enshrouded.ts new file mode 100644 index 000000000000..f71605c2f490 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/enshrouded.ts @@ -0,0 +1,11 @@ +import { EnshroudedConditionPropsMap } from "../conditions/enshrouded"; +type EnshroudedInstructionType = keyof EnshroudedConditionPropsMap; + +export const EnshroudedInstructions: Record = { + "enshrouded.player_eliminated": "React to {player} being eliminated in Enshrouded. 8 words max.", + "enshrouded.level_up": "Celebrate {player} leveling up! 8 words max.", + "enshrouded.soul_discovered": "React to {player} discovering a soul. 8 words max.", + "enshrouded.quest_update": "React to {player}'s quest being updated. 8 words max.", +}; + +export default EnshroudedInstructions; diff --git a/app/services/stream-avatar/engine/instructions/f125.ts b/app/services/stream-avatar/engine/instructions/f125.ts new file mode 100644 index 000000000000..a6cbdab0110e --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/f125.ts @@ -0,0 +1,12 @@ +import { F125ConditionPropsMap } from "../conditions/f125"; +type F125InstructionType = keyof F125ConditionPropsMap; + +export const F125Instructions: Record = { + "f1_25.game_start": "React to the race starting. 8 words max.", + "f1_25.game_end": "React to {player} crossing the chequered flag. 8 words max.", + "f1_25.victory": "Celebrate {player} winning the race in P1! 8 words max.", + "f1_25.position_change": "React to {player}'s position changing on track. 8 words max.", + "f1_25.lap_change": "React to {player} starting a new lap. 8 words max.", +}; + +export default F125Instructions; diff --git a/app/services/stream-avatar/engine/instructions/fortnite.ts b/app/services/stream-avatar/engine/instructions/fortnite.ts new file mode 100644 index 000000000000..2b338747c210 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/fortnite.ts @@ -0,0 +1,30 @@ +import { FortniteConditionPropsMap } from "../conditions/fortnite"; +type FortniteInstructionType = keyof FortniteConditionPropsMap; + +export const FortniteInstructions: Record = { + "fortnite.game_started": "React to the game starting. 8 words max.", + "fortnite.deployed": "React to {player} dropping in. 8 words max.", + "fortnite.storm_closing": "Warn about the storm closing in. 8 words max.", + "fortnite.game_ended": "React to the game ending. 8 words max.", + "fortnite.low_health": + "Panic about {player}'s critically low health. 8 words max.", + "fortnite.has_shield": "React to {player} having shield. 8 words max.", + "fortnite.no_shield": + "Warn {player} they have no shield. 8 words max.", + "fortnite.victory_royale": + "Celebrate {player}'s Victory Royale! 8 words max.", + "fortnite.player_eliminated": + "React to {player} getting eliminated. 8 words max.", + "fortnite.player_knocked": + "React to {player} getting knocked. 8 words max.", + "fortnite.defeat": "React to {player}'s defeat. 8 words max.", + "fortnite.elimination": "React to the enemy being eliminated. 8 words max.", + "fortnite.knocked": + "React to {player} knocking an enemy. 8 words max.", + "fortnite.elimination_count": + "React to {player} reaching {elimination_count} eliminations. 8 words max.", + "fortnite.players_remaining": + "Comment on {players_remaining} players remaining. 8 words max.", +}; + +export default FortniteInstructions; diff --git a/app/services/stream-avatar/engine/instructions/forzahorizon6.ts b/app/services/stream-avatar/engine/instructions/forzahorizon6.ts new file mode 100644 index 000000000000..13c171ace9ce --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/forzahorizon6.ts @@ -0,0 +1,14 @@ +import { ForzaHorizon6ConditionPropsMap } from "../conditions/forzahorizon6"; +type ForzaHorizon6InstructionType = keyof ForzaHorizon6ConditionPropsMap; + +export const ForzaHorizon6Instructions: Record = { + "forza_horizon_6.game_start": "React to the race starting. 8 words max.", + "forza_horizon_6.game_end": "React to {player} finishing the race. 8 words max.", + "forza_horizon_6.position_change": "React to {player}'s race position changing. 8 words max.", + "forza_horizon_6.lap_change": "React to {player} starting a new lap. 8 words max.", + "forza_horizon_6.great_drift": "React to {player} pulling off an epic drift. 8 words max.", + "forza_horizon_6.great_air": "React to {player} catching massive air. 8 words max.", + "forza_horizon_6.great_skill_chain": "React to {player} landing a great skill chain. 8 words max.", +}; + +export default ForzaHorizon6Instructions; diff --git a/app/services/stream-avatar/engine/instructions/index.ts b/app/services/stream-avatar/engine/instructions/index.ts new file mode 100644 index 000000000000..5f73ea189880 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/index.ts @@ -0,0 +1,56 @@ +import FortniteInstructions from "./fortnite"; +import PubgInstructions from "./pubg"; +import ValorantInstructions from "./valorant"; +import CounterStrike2Instructions from "./counterstrike2"; +import WarzoneInstructions from "./warzone"; +import ArcRaidersInstructions from "./arcraiders"; +import BlackOps6Instructions from "./blackops6"; +import RocketLeagueInstructions from "./rocketleague"; +import MinecraftInstructions from "./minecraft"; +import ApexLegendsInstructions from "./apexlegends"; +import Battlefield6Instructions from "./battlefield6"; +import DeadByDaylightInstructions from "./deadbydaylight"; +import DeadlockInstructions from "./deadlock"; +import Dota2Instructions from "./dota2"; +import LeagueOfLegendsInstructions from "./leagueoflegends"; +import MarvelRivalsInstructions from "./marvelrivals"; +import Overwatch2Instructions from "./overwatch2"; +import RainbowSixSiegeInstructions from "./rainbowsixsiege"; +import WarThunderInstructions from "./warthunder"; +import MarathonInstructions from "./marathon"; +import F125Instructions from "./f125"; +import EaSportsFc26Instructions from "./easportsfc26"; +import Nba2k26Instructions from "./nba2k26"; +import ForzaHorizon6Instructions from "./forzahorizon6"; +import EnshroudedInstructions from "./enshrouded"; + +export const Instructions = { + ...FortniteInstructions, + ...PubgInstructions, + ...ValorantInstructions, + ...CounterStrike2Instructions, + ...WarzoneInstructions, + ...ArcRaidersInstructions, + ...BlackOps6Instructions, + ...RocketLeagueInstructions, + ...MinecraftInstructions, + ...ApexLegendsInstructions, + ...Battlefield6Instructions, + ...DeadByDaylightInstructions, + ...DeadlockInstructions, + ...Dota2Instructions, + ...LeagueOfLegendsInstructions, + ...MarvelRivalsInstructions, + ...Overwatch2Instructions, + ...RainbowSixSiegeInstructions, + ...WarThunderInstructions, + ...MarathonInstructions, + ...F125Instructions, + ...EaSportsFc26Instructions, + ...Nba2k26Instructions, + ...ForzaHorizon6Instructions, + ...EnshroudedInstructions, +} as const; + +export type InstructionType = keyof typeof Instructions; +export type TInstruction = InstructionType; diff --git a/app/services/stream-avatar/engine/instructions/leagueoflegends.ts b/app/services/stream-avatar/engine/instructions/leagueoflegends.ts new file mode 100644 index 000000000000..0fe6e135051e --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/leagueoflegends.ts @@ -0,0 +1,17 @@ +import { LeagueOfLegendsConditionPropsMap } from "../conditions/leagueoflegends"; +type LeagueOfLegendsInstructionType = keyof LeagueOfLegendsConditionPropsMap; + +export const LeagueOfLegendsInstructions: Record = { + "league_of_legends.game_start": "React to minions spawning and the match starting. 8 words max.", + "league_of_legends.game_end": "React to the match ending. 8 words max.", + "league_of_legends.victory": "Celebrate {player}'s team destroying the Nexus! 8 words max.", + "league_of_legends.defeat": "React to {player}'s team losing. 8 words max.", + "league_of_legends.elimination": "React to {player} getting a kill. 8 words max.", + "league_of_legends.player_eliminated": "React to {player}'s champion dying. 8 words max.", + "league_of_legends.objective_ally": "React to {player}'s team securing an objective. 8 words max.", + "league_of_legends.objective_enemy": "React to the enemy stealing an objective. 8 words max.", + "league_of_legends.enemy_turret_destroyed": "React to an enemy turret falling. 8 words max.", + "league_of_legends.ally_turret_destroyed": "React to an ally turret being lost. 8 words max.", +}; + +export default LeagueOfLegendsInstructions; diff --git a/app/services/stream-avatar/engine/instructions/marathon.ts b/app/services/stream-avatar/engine/instructions/marathon.ts new file mode 100644 index 000000000000..57b2f8bd555d --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/marathon.ts @@ -0,0 +1,15 @@ +import { MarathonConditionPropsMap } from "../conditions/marathon"; +type MarathonInstructionType = keyof MarathonConditionPropsMap; + +export const MarathonInstructions: Record = { + "marathon.game_start": "React to the extraction mission starting. 8 words max.", + "marathon.game_end": "React to the match ending. 8 words max.", + "marathon.victory": "Celebrate {player} successfully exfiltrating! 8 words max.", + "marathon.defeat": "React to {player} being eliminated before extraction. 8 words max.", + "marathon.player_knocked": "React to {player} going down. 8 words max.", + "marathon.player_eliminated": "React to {player} being eliminated. 8 words max.", + "marathon.elimination": "React to an enemy runner being eliminated. 8 words max.", + "marathon.knockout": "React to a runner being knocked down. 8 words max.", +}; + +export default MarathonInstructions; diff --git a/app/services/stream-avatar/engine/instructions/marvelrivals.ts b/app/services/stream-avatar/engine/instructions/marvelrivals.ts new file mode 100644 index 000000000000..9191078965bc --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/marvelrivals.ts @@ -0,0 +1,12 @@ +import { MarvelRivalsConditionPropsMap } from "../conditions/marvelrivals"; +type MarvelRivalsInstructionType = keyof MarvelRivalsConditionPropsMap; + +export const MarvelRivalsInstructions: Record = { + "marvel_rivals.game_end": "React to the match ending. 8 words max.", + "marvel_rivals.victory": "Celebrate {player}'s team winning! 8 words max.", + "marvel_rivals.defeat": "React to {player}'s team losing. 8 words max.", + "marvel_rivals.elimination": "React to a hero being eliminated. 8 words max.", + "marvel_rivals.player_eliminated": "React to {player}'s hero being eliminated. 8 words max.", +}; + +export default MarvelRivalsInstructions; diff --git a/app/services/stream-avatar/engine/instructions/minecraft.ts b/app/services/stream-avatar/engine/instructions/minecraft.ts new file mode 100644 index 000000000000..ba0dce8a88a6 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/minecraft.ts @@ -0,0 +1,27 @@ +import { MinecraftConditionPropsMap } from "../conditions/minecraft"; + +type MinecraftInstructionType = keyof MinecraftConditionPropsMap; + +export const MinecraftInstructions: Record = + { + "minecraft.ender_dragon_spawned": + "React to the Ender Dragon spawning. 8 words max.", + "minecraft.boss_killed": + "Celebrate {player} defeating the boss! 8 words max.", + "minecraft.wither_spawned": + "React to {player} summoning the Wither. 8 words max.", + "minecraft.advancement_made": + "React to {player} earning an advancement. 8 words max.", + "minecraft.first_diamond": + "Celebrate {player} finding diamonds! 8 words max.", + "minecraft.nether_entered": + "React to {player} entering the Nether. 8 words max.", + "minecraft.player_eliminated": + "React to {player} dying in Minecraft. 8 words max.", + "minecraft.low_health": + "Panic about {player}'s low health. 8 words max.", + "minecraft.totem_of_undying_used": + "React to {player} using a Totem of Undying. 8 words max.", + }; + +export default MinecraftInstructions; diff --git a/app/services/stream-avatar/engine/instructions/nba2k26.ts b/app/services/stream-avatar/engine/instructions/nba2k26.ts new file mode 100644 index 000000000000..200885673336 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/nba2k26.ts @@ -0,0 +1,11 @@ +import { Nba2k26ConditionPropsMap } from "../conditions/nba2k26"; +type Nba2k26InstructionType = keyof Nba2k26ConditionPropsMap; + +export const Nba2k26Instructions: Record = { + "nba_2k26.game_start": "React to the game tipping off. 8 words max.", + "nba_2k26.game_end": "React to the final buzzer. 8 words max.", + "nba_2k26.goal": "React to a basket being scored! 8 words max.", + "nba_2k26.halftime": "React to halftime. 8 words max.", +}; + +export default Nba2k26Instructions; diff --git a/app/services/stream-avatar/engine/instructions/overwatch2.ts b/app/services/stream-avatar/engine/instructions/overwatch2.ts new file mode 100644 index 000000000000..9a37d2439e2a --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/overwatch2.ts @@ -0,0 +1,13 @@ +import { Overwatch2ConditionPropsMap } from "../conditions/overwatch2"; +type Overwatch2InstructionType = keyof Overwatch2ConditionPropsMap; + +export const Overwatch2Instructions: Record = { + "overwatch_2.round_start": "React to the round starting. 8 words max.", + "overwatch_2.round_end": "React to the round ending. 8 words max.", + "overwatch_2.victory": "Celebrate {player}'s team winning! 8 words max.", + "overwatch_2.defeat": "React to {player}'s team losing. 8 words max.", + "overwatch_2.elimination": "React to an enemy being eliminated. 8 words max.", + "overwatch_2.player_eliminated": "React to {player} being eliminated. 8 words max.", +}; + +export default Overwatch2Instructions; diff --git a/app/services/stream-avatar/engine/instructions/pubg.ts b/app/services/stream-avatar/engine/instructions/pubg.ts new file mode 100644 index 000000000000..a3a867331162 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/pubg.ts @@ -0,0 +1,21 @@ +import { PubgConditionPropsMap } from "../conditions/pubg"; +type PubgInstructionType = keyof PubgConditionPropsMap; + +export const PubgInstructions: Record = { + "pubg.game_started": "React to the match starting. 8 words max.", + "pubg.deployed": "React to {player} parachuting in. 8 words max.", + "pubg.storm_closing": "Warn about the blue zone closing. 8 words max.", + "pubg.game_ended": "React to the match ending. 8 words max.", + "pubg.victory": "Celebrate {player}'s chicken dinner! 8 words max.", + "pubg.player_eliminated": "React to {player} being eliminated. 8 words max.", + "pubg.player_knocked": "React to {player} getting knocked. 8 words max.", + "pubg.defeat": "React to {player}'s defeat. 8 words max.", + "pubg.elimination": "React to an enemy being eliminated. 8 words max.", + "pubg.knocked": "React to {player} knocking an enemy. 8 words max.", + "pubg.elimination_count": + "React to {player} reaching {elimination_count} eliminations. 8 words max.", + "pubg.players_remaining": + "Comment on {players_remaining} players remaining. 8 words max.", +}; + +export default PubgInstructions; diff --git a/app/services/stream-avatar/engine/instructions/rainbowsixsiege.ts b/app/services/stream-avatar/engine/instructions/rainbowsixsiege.ts new file mode 100644 index 000000000000..9c77f11e294b --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/rainbowsixsiege.ts @@ -0,0 +1,14 @@ +import { RainbowSixSiegeConditionPropsMap } from "../conditions/rainbowsixsiege"; +type RainbowSixSiegeInstructionType = keyof RainbowSixSiegeConditionPropsMap; + +export const RainbowSixSiegeInstructions: Record = { + "rainbow_six_siege.round_start": "React to the preparation phase starting. 8 words max.", + "rainbow_six_siege.action_phase": "React to the action phase beginning. 8 words max.", + "rainbow_six_siege.round_end": "React to the round ending. 8 words max.", + "rainbow_six_siege.victory": "React to {player}'s team winning the round. 8 words max.", + "rainbow_six_siege.defeat": "React to {player}'s team losing the round. 8 words max.", + "rainbow_six_siege.elimination": "React to an enemy operator being eliminated. 8 words max.", + "rainbow_six_siege.player_eliminated": "React to {player} being eliminated. 8 words max.", +}; + +export default RainbowSixSiegeInstructions; diff --git a/app/services/stream-avatar/engine/instructions/rocketleague.ts b/app/services/stream-avatar/engine/instructions/rocketleague.ts new file mode 100644 index 000000000000..efefa8838bf1 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/rocketleague.ts @@ -0,0 +1,17 @@ +import { RocketLeagueConditionPropsMap } from "../conditions/rocketleague"; + +type RocketLeagueInstructionType = keyof RocketLeagueConditionPropsMap; + +export const RocketLeagueInstructions: Record< + RocketLeagueInstructionType, + string +> = { + "rocket_league.game_start": "React to the match starting. 8 words max.", + "rocket_league.game_end": "React to the match ending. 8 words max.", + "rocket_league.team_scored": "React to {player}'s team scoring a goal. 8 words max.", + "rocket_league.opponent_scored": "React to the opponent scoring. 8 words max.", + "rocket_league.victory": "Celebrate {player}'s Rocket League win! 8 words max.", + "rocket_league.defeat": "React to {player}'s Rocket League loss. 8 words max.", +}; + +export default RocketLeagueInstructions; diff --git a/app/services/stream-avatar/engine/instructions/valorant.ts b/app/services/stream-avatar/engine/instructions/valorant.ts new file mode 100644 index 000000000000..9702f1578544 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/valorant.ts @@ -0,0 +1,15 @@ +import { ValorantConditionPropsMap } from "../conditions/valorant"; +type ValorantInstructionType = keyof ValorantConditionPropsMap; + +export const ValorantInstructions: Record = { + "valorant.round_started": "React to the round starting. 8 words max.", + "valorant.low_health": "Panic about {player}'s low health. 8 words max.", + "valorant.victory": "Celebrate {player}'s team winning. 8 words max.", + "valorant.player_eliminated": "React to {player} being eliminated. 8 words max.", + "valorant.defeat": "React to {player}'s team losing. 8 words max.", + "valorant.elimination": "React to an enemy being eliminated. 8 words max.", + "valorant.elimination_count": + "React to {player} reaching {elimination_count} kills. 8 words max.", +}; + +export default ValorantInstructions; diff --git a/app/services/stream-avatar/engine/instructions/warthunder.ts b/app/services/stream-avatar/engine/instructions/warthunder.ts new file mode 100644 index 000000000000..2e80d29933ff --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/warthunder.ts @@ -0,0 +1,8 @@ +import { WarThunderConditionPropsMap } from "../conditions/warthunder"; +type WarThunderInstructionType = keyof WarThunderConditionPropsMap; + +export const WarThunderInstructions: Record = { + "war_thunder.elimination": "React to {player} destroying an enemy. 8 words max.", +}; + +export default WarThunderInstructions; diff --git a/app/services/stream-avatar/engine/instructions/warzone.ts b/app/services/stream-avatar/engine/instructions/warzone.ts new file mode 100644 index 000000000000..cdcd7773fa83 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/warzone.ts @@ -0,0 +1,22 @@ +import { WarzoneConditionPropsMap } from "../conditions/warzone"; +type WarzoneInstructionType = keyof WarzoneConditionPropsMap; + +export const WarzoneInstructions: Record = { + "warzone.deploy": "React to {player} deploying in. 8 words max.", + "warzone.gulag_start": "React to {player} being sent to the Gulag. 8 words max.", + "warzone.gulag_end": "React to {player}'s Gulag result. 8 words max.", + "warzone.spectating": "React to {player} getting eliminated and spectating. 8 words max.", + "warzone.redeploying": "React to {player} redeploying back in. 8 words max.", + "warzone.victory": "Celebrate {player}'s Warzone victory! 8 words max.", + "warzone.player_knocked": "React to {player} getting knocked. 8 words max.", + "warzone.player_eliminated": "React to {player} being eliminated. 8 words max.", + "warzone.defeat": "React to {player}'s defeat. 8 words max.", + "warzone.elimination": "React to an enemy being eliminated. 8 words max.", + "warzone.knockout": "React to an enemy getting knocked. 8 words max.", + "warzone.elimination_count": + "React to {player} reaching {elimination_count} eliminations. 8 words max.", + "warzone.players_remaining": + "Comment on {players_remaining} players remaining. 8 words max.", +}; + +export default WarzoneInstructions; diff --git a/app/services/stream-avatar/engine/properties.ts b/app/services/stream-avatar/engine/properties.ts new file mode 100644 index 000000000000..165faea13e90 --- /dev/null +++ b/app/services/stream-avatar/engine/properties.ts @@ -0,0 +1,71 @@ +import type { ActionContext } from './actions'; + +type MaybePromise = T | Promise; + +abstract class PropertyBase & { default?: T }, E = T> { + value: T; + config: Config; + + constructor(config: Config) { + this.config = config; + this.value = config?.default as T; + } + + valueFromExport(v: E, _context: ActionContext): MaybePromise { + return v as unknown as T; + } + valueToExport(v: T, _context: ActionContext): MaybePromise { + return v as unknown as E; + } +} + +class SceneProperty extends PropertyBase<{ id: string }, { label: string }, { name: string }> { + override valueFromExport(v: { name: string }, { resolveSceneId }: ActionContext) { + return resolveSceneId(v).then(scene => ({ id: scene.id })); + } + override valueToExport(v: { id: string }, { resolveSceneId }: ActionContext) { + return resolveSceneId(v).then(scene => ({ name: scene.name })); + } +} + +class SourceProperty extends PropertyBase<{ id: string }, { label: string }, { name: string }> { + override valueFromExport(v: { name: string }, { resolveSourceId }: ActionContext) { + return resolveSourceId(v).then(source => ({ id: source.id })); + } + override valueToExport(v: { id: string }, { resolveSourceId }: ActionContext) { + return resolveSourceId(v).then(source => ({ name: source.name })); + } +} + +class SliderProperty extends PropertyBase< + number, + { label: string; default: number; min: number; max: number; step: number; format?: (v: number) => string } +> {} + +class SliderRangeProperty extends PropertyBase< + [number, number], + { label: string; default: [number, number]; min: number; max: number; step: number; format?: (v: [number, number]) => string } +> {} + +class CheckboxProperty extends PropertyBase {} + +class TextProperty extends PropertyBase { + override valueFromExport(v: string) { + return v; + } + override valueToExport(v: string) { + return v; + } +} + +export const Properties = { + Scene: SceneProperty, + Source: SourceProperty, + Slider: SliderProperty, + SliderRange: SliderRangeProperty, + Checkbox: CheckboxProperty, + Text: TextProperty, +} as const; + +export type PropertyMap = Record | undefined; +export type PropertyInstance = InstanceType<(typeof Properties)[keyof typeof Properties]>; diff --git a/app/services/stream-avatar/engine/validation.ts b/app/services/stream-avatar/engine/validation.ts new file mode 100644 index 000000000000..6423ed432a65 --- /dev/null +++ b/app/services/stream-avatar/engine/validation.ts @@ -0,0 +1,159 @@ +import { $t } from 'services/i18n'; +import { Conditions } from './conditions'; +import type { TAutomationExport } from './automations'; +import type { ExportedAction, ExportedActionProps } from './actions'; + +/** A scene or source that currently exists in the active scene collection. */ +export interface IResourceRef { + id: string; + name: string; +} + +/** Live scenes/sources used to detect deleted or unavailable references. */ +export interface IAvailableResources { + scenes: IResourceRef[]; + sources: IResourceRef[]; +} + +/** Where an issue applies, so the editor can render it next to the right field. */ +export type TIssueScope = 'description' | 'conditions' | 'action'; + +export interface IAutomationIssue { + scope: TIssueScope; + /** Index into `automation.actions` when scope === 'action'. */ + actionIndex?: number; + /** The offending prop, e.g. 'scene' | 'source' | 'instruction'. */ + field?: string; + message: string; +} + +export const MAX_DESCRIPTION_LENGTH = 100; +export const MAX_INSTRUCTION_LENGTH = 128; + +/** + * Translates `key` and substitutes `%{name}` placeholders. VueI18n only + * interpolates keys that exist in the dictionary; these strings are new and not + * synced yet, so we fill any placeholders left in the returned key ourselves. + */ +function t(key: string, vars?: Record): string { + const translated = vars ? $t(key, vars) : $t(key); + if (!vars) return translated; + return translated.replace(/%\{(\w+)\}/g, (match, name) => + name in vars ? String(vars[name]) : match, + ); +} + +function validateAction( + action: ExportedAction, + index: number, + resources: IAvailableResources, +): IAutomationIssue[] { + const issues: IAutomationIssue[] = []; + const props = (action?.props ?? {}) as ExportedActionProps; + + const actionIssue = (field: string, message: string): IAutomationIssue => ({ + scope: 'action', + actionIndex: index, + field, + message, + }); + + switch (action?.type) { + case 'common.switch_to_scene': { + const name = props.scene?.name?.trim(); + if (!name) { + issues.push(actionIssue('scene', $t('Select a scene to switch to.'))); + } else if (!resources.scenes.some(s => s.name === name)) { + issues.push( + actionIssue('scene', t('Scene "%{name}" no longer exists.', { name })), + ); + } + break; + } + + case 'common.show_source': + case 'common.hide_source': { + const name = props.source?.name?.trim(); + if (!name) { + issues.push(actionIssue('source', $t('Select a source.'))); + } else if (!resources.sources.some(s => s.name === name)) { + issues.push( + actionIssue('source', t('Source "%{name}" is unavailable.', { name })), + ); + } + break; + } + + case 'co-host.instruction': { + const instruction = props.instruction?.trim(); + if (!instruction) { + issues.push(actionIssue('instruction', $t('Enter an instruction for the co-host.'))); + } else if (instruction.length > MAX_INSTRUCTION_LENGTH) { + issues.push( + actionIssue( + 'instruction', + t('Instruction must be %{max} characters or fewer.', { + max: MAX_INSTRUCTION_LENGTH, + }), + ), + ); + } + break; + } + + default: + break; + } + + return issues; +} + +/** + * Validates an automation against the live scenes/sources. Returns every issue + * found so the UI can both block submission and surface real-time errors (e.g. + * a Switch Scene action pointing at a scene that has since been deleted). + */ +export function validateAutomation( + automation: TAutomationExport, + resources: IAvailableResources, +): IAutomationIssue[] { + const issues: IAutomationIssue[] = []; + + const description = automation.description?.trim(); + if (!description) { + issues.push({ scope: 'description', message: $t('Add a description.') }); + } else if (description.length > MAX_DESCRIPTION_LENGTH) { + issues.push({ + scope: 'description', + message: t('Description must be %{max} characters or fewer.', { + max: MAX_DESCRIPTION_LENGTH, + }), + }); + } + + if (!automation.conditions?.length) { + issues.push({ scope: 'conditions', message: $t('Select a condition.') }); + } else if ( + automation.conditions.some(c => !c?.type || !Conditions[c.type as keyof typeof Conditions]) + ) { + issues.push({ scope: 'conditions', message: $t('This automation uses an unknown condition.') }); + } + + const validActions = (automation.actions ?? []).filter(a => a?.type); + if (!validActions.length) { + issues.push({ scope: 'action', message: $t('Add at least one action.') }); + } + + (automation.actions ?? []).forEach((action, index) => { + issues.push(...validateAction(action, index, resources)); + }); + + return issues; +} + +export function isAutomationValid( + automation: TAutomationExport, + resources: IAvailableResources, +): boolean { + return validateAutomation(automation, resources).length === 0; +} diff --git a/app/services/stream-avatar/stream-avatar-api-service.ts b/app/services/stream-avatar/stream-avatar-api-service.ts new file mode 100644 index 000000000000..8c3a45400be7 --- /dev/null +++ b/app/services/stream-avatar/stream-avatar-api-service.ts @@ -0,0 +1,70 @@ +import { Service } from 'services/core'; +import { Inject } from 'services/core/injector'; +import { UserService } from 'services/user'; +import { HostsService } from 'services/hosts'; +import { authorizedHeaders, jfetch } from 'util/requests'; +import Util from 'services/utils'; + +export class StreamAvatarApiService extends Service { + @Inject() private userService: UserService; + @Inject() private hostsService: HostsService; + + private cachedJwt: string | null = null; + private cachedJwtExp = 0; + private inflight: Promise | null = null; + + private get apiBase(): string { + const protocol = Util.shouldUseAvatarLocalHost() ? 'http://' : 'https://'; + return `${protocol}${this.hostsService.streamAvatarApi}`; + } + + async getToken(forceRefresh = false): Promise { + const now = Date.now() / 1000; + if (!forceRefresh && this.cachedJwt && this.cachedJwtExp - now > 60) { + return this.cachedJwt; + } + + if (this.inflight) return this.inflight; + + this.inflight = this.mintToken().finally(() => { + this.inflight = null; + }); + + return this.inflight; + } + + private async mintToken(): Promise { + const response = await jfetch<{ token: string }>( + new Request(`${this.apiBase}/token/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: this.userService.apiToken }), + }), + ); + + const jwt = response.token; + // Decode the exp from the JWT payload (second base64url segment) + const payloadB64 = jwt.split('.')[1]; + const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))); + this.cachedJwt = jwt; + this.cachedJwtExp = payload.exp; + return jwt; + } + + async authedFetch(path: string, init: RequestInit = {}): Promise { + const makeRequest = (token: string) => + new Request(`${this.apiBase}${path}`, { + ...init, + headers: authorizedHeaders(token, new Headers({ 'Content-Type': 'application/json' })), + }); + + try { + return await jfetch(makeRequest(await this.getToken())); + } catch (e: any) { + if (e?.status === 401) { + return await jfetch(makeRequest(await this.getToken(true))); + } + throw e; + } + } +} diff --git a/app/services/utils.ts b/app/services/utils.ts index b647be708238..ccd2853fc7fb 100644 --- a/app/services/utils.ts +++ b/app/services/utils.ts @@ -133,6 +133,10 @@ export default class Utils { return Utils.env.SLOBS_USE_LOCAL_HOST as boolean; } + static shouldUseAvatarLocalHost(): boolean { + return true; + } + static shouldUseBeta(): boolean { return (process.env.SLD_COMPILE_FOR_BETA || Utils.env.SLD_USE_BETA) as boolean; } diff --git a/app/services/windows.ts b/app/services/windows.ts index 58e6109b0f6e..5401ff720831 100644 --- a/app/services/windows.ts +++ b/app/services/windows.ts @@ -39,6 +39,7 @@ import { PlatformAppPopOut, RecentEventsWindow, EditTransform, + EditAutomations, Blank, Main, MultistreamChatInfo, @@ -99,6 +100,7 @@ export function getComponents() { MediaGallery, PlatformAppPopOut, EditTransform, + EditAutomations, OverlayPlaceholder, BrowserSourceInteraction, EventFilterMenu, From d94847250262fbb37283113891fdf35775773a97 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 3 Jun 2026 17:35:14 -0700 Subject: [PATCH 02/79] feat(automations): implement drag-and-drop reordering and enhance action editor functionality --- .../AutomationEditor.tsx | 414 ++++++++++++------ app/i18n/en-US/stream-avatar-automations.json | 4 + 2 files changed, 280 insertions(+), 138 deletions(-) diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index b16f42bc7813..58487a26bbfb 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -1,4 +1,6 @@ import React, { CSSProperties, useEffect, useState } from 'react'; +import { ReactSortable } from 'react-sortablejs'; +import uuid from 'uuid/v4'; import { ModalLayout } from 'components-react/shared/ModalLayout'; import { useVuex } from 'components-react/hooks'; import { Services } from 'components-react/service-provider'; @@ -26,6 +28,47 @@ function fieldBorder(invalid: boolean): CSSProperties { return { borderColor: invalid ? 'var(--red)' : 'var(--border)' }; } +// Drag-and-drop reordering needs a stable key per row that survives reorders and +// inserts (using the array index would make React/Sortable lose track on move). +interface ActionRow { + id: string; + action: ExportedAction; +} + +function makeRow(action: ExportedAction): ActionRow { + return { id: uuid(), action: withActionDefaults(action) }; +} + +// Height of a single control line, so the grip and +/- icons align with the +// action's primary input regardless of any extra rows (checkbox, slider) below. +const CONTROL_HEIGHT = '32px'; + +const dragHandleStyle: CSSProperties = { + cursor: 'grab', + color: 'var(--paragraph)', + fontSize: '16px', +}; + +const gripCellStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + height: CONTROL_HEIGHT, +}; + +const actionsCellStyle: CSSProperties = { + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + gap: '10px', + height: CONTROL_HEIGHT, +}; + +const iconButtonStyle: CSSProperties = { + cursor: 'pointer', + color: 'var(--icon-active)', + fontSize: '16px', +}; + const ACTION_OPTIONS = Object.entries(ActionRegistry).map(([type, def]) => ({ type: type as ActionType, label: def.label, @@ -48,6 +91,13 @@ const inputStyle: CSSProperties = { boxSizing: 'border-box', }; +// Full-width control that lines up with the row's fixed control height. +const rowControlStyle: CSSProperties = { + ...inputStyle, + width: '100%', + height: CONTROL_HEIGHT, +}; + const labelStyle: CSSProperties = { display: 'block', marginBottom: '4px', @@ -58,20 +108,24 @@ const labelStyle: CSSProperties = { interface ActionEditorProps { action: ExportedAction; index: number; + isFirst: boolean; scenes: { id: string; name: string }[]; sources: { id: string; name: string }[]; errors?: Record; onChange: (index: number, action: ExportedAction) => void; + onInsert: (index: number) => void; onRemove: (index: number) => void; } function ActionEditor({ action, index, + isFirst, scenes, sources, errors, onChange, + onInsert, onRemove, }: ActionEditorProps) { function setType(type: ActionType) { @@ -92,134 +146,193 @@ function ActionEditor({ const sourceName = props.source?.name ?? ''; const sourceMissing = !!sourceName && !sources.some(s => s.name === sourceName); + // The action's inline control (column 3), stacked vertically when it has more + // than one row (e.g. a source select plus its checkbox). + function renderControl() { + switch (action.type) { + case 'common.switch_to_scene': + return ( + <> + + {errors?.scene &&

{errors.scene}

} + + ); + + case 'common.show_source': + case 'common.hide_source': + return ( + <> + + {errors?.source &&

{errors.source}

} + {action.type === 'common.show_source' && ( + + )} + {action.type === 'common.hide_source' && ( + + )} + + ); + + case 'common.wait_for_ms': + return ( + <> +
+ {$t('Duration')} + + {((props.duration ?? 5000) / 1000).toFixed(1)} {$t('seconds')} + +
+ setProp('duration', Number(e.target.value))} + style={{ width: '100%' }} + /> + + ); + + case 'co-host.instruction': + return ( + <> + setProp('instruction', e.target.value)} + placeholder={$t('Instruction')} + maxLength={MAX_INSTRUCTION_LENGTH} + style={{ ...rowControlStyle, ...fieldBorder(!!errors?.instruction) }} + /> + {errors?.instruction &&

{errors.instruction}

} + + ); + + case 'co-host.comment': + return ( +

+ {$t('The co-host will automatically comment based on the active game condition.')} +

+ ); + + default: + return null; + } + } + return (
-
- - +
+
- {action.type === 'common.switch_to_scene' && ( - <> - - {errors?.scene &&

{errors.scene}

} - - )} - - {(action.type === 'common.show_source' || action.type === 'common.hide_source') && ( -
- - {errors?.source &&

{errors.source}

} - {action.type === 'common.show_source' && ( - - )} - {action.type === 'common.hide_source' && ( - - )} -
- )} - - {action.type === 'common.wait_for_ms' && ( -
- - setProp('duration', Number(e.target.value))} - style={{ width: '100%' }} - /> -
- )} - - {action.type === 'co-host.comment' && ( -

- {$t('The co-host will automatically comment based on the active game condition.')} -

- )} + + +
+ {renderControl()} +
- {action.type === 'co-host.instruction' && ( - <> - setProp('instruction', e.target.value)} - placeholder={$t('Instruction')} - maxLength={MAX_INSTRUCTION_LENGTH} - style={{ ...inputStyle, width: '100%', ...fieldBorder(!!errors?.instruction) }} +
+ onInsert(index)} + /> + {isFirst ? ( + + ) : ( + onRemove(index)} /> - {errors?.instruction &&

{errors.instruction}

} - - )} + )} +
); } @@ -247,11 +360,15 @@ export default function AutomationEditor({ initial, onClose }: Props) { const [conditionType, setConditionType] = useState(() => { return (initial?.conditions?.[0]?.type as ConditionType) ?? ''; }); - const [actions, setActions] = useState( - (initial?.actions as ExportedAction[])?.filter(a => a?.type).map(withActionDefaults) ?? [ - withActionDefaults({ type: 'common.save_replay' }), - ], + const [rows, setRows] = useState( + () => + (initial?.actions as ExportedAction[])?.filter(a => a?.type).map(makeRow) ?? [ + makeRow({ type: 'common.save_replay' }), + ], ); + // `rows` carries the stable drag keys; `actions` is the plain ordered list the + // validator and save payload consume. + const actions = rows.map(r => r.action); // Enable/disable is managed from the list, not here; new automations default // to enabled and edits preserve the existing value. const enabled = initial?.enabled ?? true; @@ -302,15 +419,25 @@ export default function AutomationEditor({ initial, onClose }: Props) { }, [selectedGame]); function handleActionChange(index: number, action: ExportedAction) { - setActions(prev => prev.map((a, i) => (i === index ? action : a))); + setRows(prev => prev.map((r, i) => (i === index ? { ...r, action } : r))); } function handleActionRemove(index: number) { - setActions(prev => prev.filter((_, i) => i !== index)); + setRows(prev => prev.filter((_, i) => i !== index)); } function handleAddAction() { - setActions(prev => [...prev, withActionDefaults({ type: 'common.save_replay' })]); + setRows(prev => [...prev, makeRow({ type: 'common.save_replay' })]); + } + + // Insert a new action directly after `afterIndex` so steps can be added + // between existing ones, not just appended. + function handleInsertAction(afterIndex: number) { + setRows(prev => { + const next = [...prev]; + next.splice(afterIndex + 1, 0, makeRow({ type: 'common.save_replay' })); + return next; + }); } async function handleSave() { @@ -421,18 +548,29 @@ export default function AutomationEditor({ initial, onClose }: Props) { {/* Actions */}
- {actions.map((action, i) => ( - - ))} + + list={rows} + setList={setRows} + handle=".sa-action-drag-handle" + animation={200} + tag="div" + > + {rows.map((row, i) => ( +
+ +
+ ))} + {actionsError &&

{actionsError}

} ); @@ -533,7 +547,12 @@ export default function AutomationEditor({ initial, onClose }: Props) { setProp('scene', { name: e.target.value })} - style={{ ...rowControlStyle, ...fieldBorder(!!errors?.scene) }} - > - - {sceneMissing && ( - - )} - {scenes.map(s => ( - - ))} - + setProp('source', { name: e.target.value })} - style={{ ...rowControlStyle, ...fieldBorder(!!errors?.source) }} - > - - {sourceMissing && ( - - )} - {sources.map(s => ( - - ))} - + setProp('hide_if_condition_false', e.target.checked)} - /> - {$t('Hide if condition is false')} - + setProp('hide_if_condition_false', val)} + label={$t('Hide if condition is false')} + /> )} {action.type === 'common.hide_source' && ( - + setProp('show_if_condition_false', val)} + label={$t('Show if condition is false')} + /> )} ); + } case 'common.wait_for_ms': return ( @@ -239,14 +209,14 @@ function ActionEditor({ {((props.duration ?? 5000) / 1000).toFixed(1)} {$t('seconds')}
- setProp('duration', val)} min={500} max={60000} step={500} - value={props.duration ?? 5000} - onChange={e => setProp('duration', Number(e.target.value))} - style={{ width: '100%' }} + tipFormatter={(val: number) => `${(val / 1000).toFixed(1)}s`} /> ); @@ -254,13 +224,11 @@ function ActionEditor({ case 'co-host.instruction': return ( <> - setProp('instruction', e.target.value)} placeholder={$t('Instruction')} maxLength={MAX_INSTRUCTION_LENGTH} - style={{ ...rowControlStyle, ...fieldBorder(!!errors?.instruction) }} /> {errors?.instruction &&

{errors.instruction}

} @@ -306,17 +274,12 @@ function ActionEditor({ />
- + onChange={val => setType(val as ActionType)} + style={{ width: '100%' }} + options={ACTION_OPTIONS} + />
{renderControl()} @@ -362,7 +325,7 @@ export default function AutomationEditor({ initial, onClose }: Props) { if (initial?.conditions?.[0]) { return (initial.conditions[0].type as string).split('.')[0]; } - return GAME_OPTIONS[0].id; + return GAME_OPTIONS[0].value; }); const [conditionType, setConditionType] = useState(() => { return (initial?.conditions?.[0]?.type as ConditionType) ?? ''; @@ -418,8 +381,8 @@ export default function AutomationEditor({ initial, onClose }: Props) { useEffect(() => { // Reset condition selection when game changes if (conditionOptions.length > 0) { - const current = conditionOptions.find(o => o.type === conditionType); - if (!current) setConditionType(conditionOptions[0].type); + const current = conditionOptions.find(o => o.value === conditionType); + if (!current) setConditionType(conditionOptions[0].value); } else { setConditionType(''); } @@ -478,12 +441,8 @@ export default function AutomationEditor({ initial, onClose }: Props) { } const getButtonText = () => { - if (saving) { - return $t('Saving...'); - } - if (initial) { - return $t('Save Changes'); - } + if (saving) return $t('Saving...'); + if (initial) return $t('Save Changes'); return $t('Create Automation'); }; @@ -511,58 +470,34 @@ export default function AutomationEditor({ initial, onClose }: Props) {
{/* Description */} -
- - setDescription(e.target.value)} - maxLength={100} - placeholder={$t('e.g. Victory Royale reaction')} - style={{ - ...inputStyle, - width: '100%', - padding: '6px 8px', - ...fieldBorder(!!descriptionError), - }} - /> - {descriptionError &&

{descriptionError}

} -
+ setDescription(val)} + placeholder={$t('e.g. Victory Royale reaction')} + validateStatus={descriptionError ? 'error' : undefined} + help={descriptionError} + /> {/* Condition */}
- - + onChange={val => setSelectedGame(val)} + style={{ flex: '0 0 auto' }} + options={GAME_OPTIONS} + /> + Date: Mon, 15 Jun 2026 13:12:01 -0700 Subject: [PATCH 07/79] feat(automations): implement AutomationsElement component and integrate with the editor --- .../editor/elements/AutomationsElement.m.less | 118 ++++++++++++++++ .../editor/elements/AutomationsElement.tsx | 130 ++++++++++++++++++ app/components-react/editor/elements/index.ts | 1 + .../EditAutomations.tsx | 14 ++ app/services/layout/layout-data.ts | 5 + .../stream-avatar/automations-service.ts | 24 +++- package.json | 2 +- 7 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 app/components-react/editor/elements/AutomationsElement.m.less create mode 100644 app/components-react/editor/elements/AutomationsElement.tsx diff --git a/app/components-react/editor/elements/AutomationsElement.m.less b/app/components-react/editor/elements/AutomationsElement.m.less new file mode 100644 index 000000000000..cac10efd00b6 --- /dev/null +++ b/app/components-react/editor/elements/AutomationsElement.m.less @@ -0,0 +1,118 @@ +@import '../../../styles/index'; + +.wrapper { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.topBar { + display: flex; + align-items: center; + padding: 4px 8px 4px 12px; + flex-shrink: 0; + + i { + font-size: 16px; + cursor: pointer; + opacity: 0.7; + + &:hover { + opacity: 1; + color: var(--icon-active); + } + } +} + +.list { + flex: 1; + background: var(--section); + border-radius: 4px; + overflow: hidden; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + cursor: pointer; + transition: background 0.1s; + + &:hover { + background: var(--solid-input); + + .name { + color: var(--title); + } + } +} + +.rowInfo { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + margin-right: 8px; +} + +.name { + font-size: 12px; + color: var(--paragraph); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; +} + +.condition { + font-size: 10px; + color: var(--paragraph); + opacity: 0.6; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +.rowActions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + + i { + font-size: 14px; + opacity: 0.7; + cursor: pointer; + + &:hover { + opacity: 1; + } + } +} + +.disabled { + opacity: 0.4 !important; + cursor: not-allowed !important; +} + +.empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--paragraph); + opacity: 0.6; + font-size: 12px; + text-align: center; + padding: 16px; +} + +.center { + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/app/components-react/editor/elements/AutomationsElement.tsx b/app/components-react/editor/elements/AutomationsElement.tsx new file mode 100644 index 000000000000..5c916f0b5c0d --- /dev/null +++ b/app/components-react/editor/elements/AutomationsElement.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Spin, Switch, Tooltip } from 'antd'; +import Scrollable from 'components-react/shared/Scrollable'; +import { useVuex } from 'components-react/hooks'; +import { Services } from 'components-react/service-provider'; +import { $t } from 'services/i18n'; +import { Conditions } from 'services/stream-avatar/engine/conditions'; +import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; +import useBaseElement from './hooks'; +import styles from './AutomationsElement.m.less'; + +const mins = { x: 220, y: 120 }; + +function conditionLabel(automation: TAutomationExport) { + const c = automation.conditions[0]; + if (!c?.type) return ''; + const def = Conditions[c.type as keyof typeof Conditions]; + return def ? def.label : c.type; +} + +function AutomationsContent() { + const { AutomationsService, AutomationsEngineService } = Services; + const { automations, loading } = useVuex(() => ({ + automations: AutomationsService.state.automations, + loading: AutomationsService.state.loading, + })); + + const [simulatingId, setSimulatingId] = useState(null); + + useEffect(() => { + AutomationsService.actions.fetchAll(); + }, []); + + async function simulate(e: React.MouseEvent, id: number) { + e.stopPropagation(); + if (simulatingId !== null) return; + setSimulatingId(id); + try { + await AutomationsEngineService.actions.return.simulateAutomation(id); + } finally { + setSimulatingId(null); + } + } + + function toggleEnabled(automation: TAutomationExport) { + if (!automation.id) return; + AutomationsService.actions.update(automation.id, { + ...automation, + enabled: !automation.enabled, + }); + } + + function openEditor() { + AutomationsService.actions.showAutomations(); + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + +
+ + {automations.length === 0 ? ( +
+ {$t('No automations yet.')} +
+ ) : ( + + {automations.map(automation => ( +
+ automation.id != null && + AutomationsService.actions.showAutomationEditor(automation.id) + } + > +
+ + {automation.description || $t('(no description)')} + + {conditionLabel(automation)} +
+
e.stopPropagation()}> + {simulatingId === automation.id ? ( + + ) : ( + + automation.id != null && simulate(e, automation.id)} + /> + + )} + toggleEnabled(automation)} + /> +
+
+ ))} +
+ )} +
+ ); +} + +export function AutomationsElement() { + const containerRef = useRef(null); + const { renderElement } = useBaseElement(, mins, containerRef.current); + + return ( +
+ {renderElement()} +
+ ); +} + +AutomationsElement.mins = mins; diff --git a/app/components-react/editor/elements/index.ts b/app/components-react/editor/elements/index.ts index 8fc50973b47d..180136798b8a 100644 --- a/app/components-react/editor/elements/index.ts +++ b/app/components-react/editor/elements/index.ts @@ -7,3 +7,4 @@ export { RecordingPreview } from './RecordingPreview'; export { StreamPreview } from './StreamPreview'; export { Browser } from './Browser'; export { Display } from './Display'; +export { AutomationsElement as Automations } from './AutomationsElement'; diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index 1a741992a5d1..fdad5ce6233f 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -51,6 +51,20 @@ export default function EditAutomations() { AutomationsService.actions.fetchAll(); }, []); + // If launched with a specific automation id, jump straight into the editor. + // Read queryParams reactively so re-opening with a different id re-triggers. + const { WindowsService } = Services; + const { editAutomationId } = useVuex(() => ({ + editAutomationId: WindowsService.state.child.queryParams?.editAutomationId as + | number + | undefined, + })); + useEffect(() => { + if (!editAutomationId || automations.length === 0) return; + const target = automations.find(a => a.id === editAutomationId); + if (target) setEditingAutomation(target); + }, [editAutomationId, automations]); + async function simulate(automation: TAutomationExport) { if (!automation.id || simulatingId !== null) return; setSimulatingId(automation.id); diff --git a/app/services/layout/layout-data.ts b/app/services/layout/layout-data.ts index 87bf70d5c069..379fd12ff80d 100644 --- a/app/services/layout/layout-data.ts +++ b/app/services/layout/layout-data.ts @@ -74,6 +74,7 @@ export enum ELayoutElement { StreamPreview = 'StreamPreview', RecordingPreview = 'RecordingPreview', Browser = 'Browser', + Automations = 'Automations', } export type TLayoutElement = `${ELayoutElement}`; @@ -122,4 +123,8 @@ export const ELEMENT_DATA = (): IElementData => ({ title: $t('Website'), component: 'Browser', }, + [ELayoutElement.Automations]: { + title: $t('Automations'), + component: 'Automations', + }, }); diff --git a/app/services/stream-avatar/automations-service.ts b/app/services/stream-avatar/automations-service.ts index e2480bf68197..9cdb98656e7f 100644 --- a/app/services/stream-avatar/automations-service.ts +++ b/app/services/stream-avatar/automations-service.ts @@ -50,6 +50,18 @@ export class AutomationsService extends StatefulService { }); } + showAutomationEditor(id: number) { + this.windowsService.showWindow({ + componentName: 'EditAutomations', + title: $t('Automations'), + size: { + width: 900, + height: 650, + }, + queryParams: { editAutomationId: id }, + }); + } + @mutation() private SET_AUTOMATIONS(automations: TAutomationExport[]) { this.state.automations = automations; @@ -99,7 +111,17 @@ export class AutomationsService extends StatefulService { } async update(id: number, automation: Partial): Promise { - const updated = await this.agentSocketService.updateAutomation({ ...automation, id }); + // Explicitly build the payload — avoid spreading the full runtime object which + // includes server-managed fields (user_id, created_at, updated_at) returned by + // getAutomations but not accepted for writes. + const payload = { + id, + description: automation.description, + conditions: automation.conditions, + actions: automation.actions, + enabled: automation.enabled, + }; + const updated = await this.agentSocketService.updateAutomation(payload); this.UPDATE_AUTOMATION(updated as TAutomationExport); return updated as TAutomationExport; } diff --git a/package.json b/package.json index 61693afcb2ab..9a48ea6b8860 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Streamlabs streaming software", "author": "General Workings, Inc.", "license": "GPL-3.0", - "version": "1.21.3", + "version": "1.21.3-automations-v2", "main": "main.js", "macVirtualCamUrl": "https://obs-studio-deployment.s3.us-west-2.amazonaws.com/slobs-vcam-installer-1.0.16-release-osx-[ARCH].tar.gz", "scripts": { From 2558e8e450a94a9531d70666153be5d98368e623 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Mon, 15 Jun 2026 13:23:45 -0700 Subject: [PATCH 08/79] feat(automations): update AutomationsElement and EditAutomations for create automation flow --- .../editor/elements/AutomationsElement.m.less | 20 +++++++------- .../editor/elements/AutomationsElement.tsx | 7 ++--- .../EditAutomations.tsx | 26 +++++++++++++++---- .../stream-avatar/automations-service.ts | 12 +++++++++ 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/app/components-react/editor/elements/AutomationsElement.m.less b/app/components-react/editor/elements/AutomationsElement.m.less index cac10efd00b6..dc1a7ea2b5e5 100644 --- a/app/components-react/editor/elements/AutomationsElement.m.less +++ b/app/components-react/editor/elements/AutomationsElement.m.less @@ -12,17 +12,17 @@ align-items: center; padding: 4px 8px 4px 12px; flex-shrink: 0; + gap: 4px; +} - i { - font-size: 16px; - cursor: pointer; - opacity: 0.7; - - &:hover { - opacity: 1; - color: var(--icon-active); - } - } +.title { + font-weight: 500; + font-size: 14px; + color: var(--title); + margin-right: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .list { diff --git a/app/components-react/editor/elements/AutomationsElement.tsx b/app/components-react/editor/elements/AutomationsElement.tsx index 5c916f0b5c0d..6e9394436517 100644 --- a/app/components-react/editor/elements/AutomationsElement.tsx +++ b/app/components-react/editor/elements/AutomationsElement.tsx @@ -50,8 +50,8 @@ function AutomationsContent() { }); } - function openEditor() { - AutomationsService.actions.showAutomations(); + function openCreate() { + AutomationsService.actions.showCreateAutomation(); } if (loading) { @@ -65,8 +65,9 @@ function AutomationsContent() { return (
+ {$t('Automations')} - +
diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index fdad5ce6233f..7e477ff2e827 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -51,14 +51,26 @@ export default function EditAutomations() { AutomationsService.actions.fetchAll(); }, []); - // If launched with a specific automation id, jump straight into the editor. - // Read queryParams reactively so re-opening with a different id re-triggers. + // Read queryParams reactively so changes re-trigger when window is reused. const { WindowsService } = Services; - const { editAutomationId } = useVuex(() => ({ + const { editAutomationId, createNew } = useVuex(() => ({ editAutomationId: WindowsService.state.child.queryParams?.editAutomationId as | number | undefined, + createNew: !!WindowsService.state.child.queryParams?.createNew, })); + + // True when launched from the AutomationsElement — close the window on done instead of returning to list. + const launchedFromElement = !!editAutomationId || createNew; + + // Jump straight into create flow when launched with createNew param. + useEffect(() => { + if (!createNew) return; + setCreating(true); + setEditingAutomation(null); + }, [createNew]); + + // Jump straight into the editor for a specific automation when launched with editAutomationId. useEffect(() => { if (!editAutomationId || automations.length === 0) return; const target = automations.find(a => a.id === editAutomationId); @@ -101,8 +113,12 @@ export default function EditAutomations() { } function closeEditor() { - setEditingAutomation(null); - setCreating(false); + if (launchedFromElement) { + WindowsService.actions.closeChildWindow(); + } else { + setEditingAutomation(null); + setCreating(false); + } } if (creating || editingAutomation) { diff --git a/app/services/stream-avatar/automations-service.ts b/app/services/stream-avatar/automations-service.ts index 9cdb98656e7f..ab20c120ab7b 100644 --- a/app/services/stream-avatar/automations-service.ts +++ b/app/services/stream-avatar/automations-service.ts @@ -62,6 +62,18 @@ export class AutomationsService extends StatefulService { }); } + showCreateAutomation() { + this.windowsService.showWindow({ + componentName: 'EditAutomations', + title: $t('Automations'), + size: { + width: 900, + height: 650, + }, + queryParams: { createNew: true }, + }); + } + @mutation() private SET_AUTOMATIONS(automations: TAutomationExport[]) { this.state.automations = automations; From 23433848e65a75d23c533d44ce367e83dd928477 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Thu, 18 Jun 2026 13:40:09 -0700 Subject: [PATCH 09/79] feat(automations): enhance Scrollable component with layout update and integrate AgentSocketService for simulation messaging --- app/components-react/shared/Scrollable.tsx | 14 +- app/services/hosts.ts | 3 +- .../stream-avatar/agent-socket-service.ts | 8 + .../automations-engine-service.ts | 55 +---- app/services/stream-avatar/engine/actions.ts | 22 +- .../stream-avatar/engine/instructions.ts | 221 +++++++++--------- 6 files changed, 164 insertions(+), 159 deletions(-) diff --git a/app/components-react/shared/Scrollable.tsx b/app/components-react/shared/Scrollable.tsx index bd6c153f9c53..db1ae300c9a5 100644 --- a/app/components-react/shared/Scrollable.tsx +++ b/app/components-react/shared/Scrollable.tsx @@ -1,4 +1,4 @@ -import React, { HTMLAttributes, useState, CSSProperties } from 'react'; +import React, { HTMLAttributes, useState, useEffect, useRef, CSSProperties } from 'react'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import { OverflowBehavior } from 'overlayscrollbars'; @@ -25,8 +25,19 @@ export default function Scrollable(initialProps: IScrollableProps & HTMLAttribut ...initialProps, }; + const osRef = useRef(null); const [wrapperStyles, setWrapperStyles] = useState({}); + useEffect(() => { + // Force a layout update on the next frame so the scrollbar reflects the + // resolved calc() height. Without this, OverlayScrollbars measures before + // the parent container has settled and misses overflow on initial open. + const id = requestAnimationFrame(() => { + osRef.current?.osInstance()?.update(true); + }); + return () => cancelAnimationFrame(id); + }, []); + function onOverflowChanged(ev?: { yScrollable: boolean }) { if (!ev) return; if (p.snapToWindowEdge && ev.yScrollable) { @@ -39,6 +50,7 @@ export default function Scrollable(initialProps: IScrollableProps & HTMLAttribut return ( , response: 'text' | 'tts' = 'tts') { const message = { type: 'trigger', diff --git a/app/services/stream-avatar/automations-engine-service.ts b/app/services/stream-avatar/automations-engine-service.ts index fa192173a176..d38a8a479d54 100644 --- a/app/services/stream-avatar/automations-engine-service.ts +++ b/app/services/stream-avatar/automations-engine-service.ts @@ -5,6 +5,7 @@ import { StreamingService } from 'services/streaming'; import { WebsocketService } from 'services/websocket'; import { VisionService, VisionProcess } from 'services/vision'; import { AutomationsService } from './automations-service'; +import { AgentSocketService } from './agent-socket-service'; import Utils from 'services/utils'; import { toUpper } from 'lodash'; import { @@ -34,6 +35,7 @@ export class AutomationsEngineService extends Service { @Inject() private websocketService: WebsocketService; @Inject() private visionService: VisionService; @Inject() private automationsService: AutomationsService; + @Inject() private agentSocketService: AgentSocketService; private gameState: GameState = { ...defaultGameState, pendingEvents: new Set() }; private prevState: GameState = { ...defaultGameState, pendingEvents: new Set() }; @@ -83,6 +85,14 @@ export class AutomationsEngineService extends Service { } this.streamingService.saveReplay(); }, + + sendInstruction: (instruction: string) => { + this.agentSocketService.sendInstruction(instruction); + }, + + sendSimulationBark: (conditionType: string) => { + this.agentSocketService.sendSimulationBark(conditionType); + }, }; this.websocketService.socketEvent.subscribe(e => { @@ -211,51 +221,8 @@ export class AutomationsEngineService extends Service { next.health = 100; break; - case 'spectating': - case 'action_phase': - case 'deploy': - case 'victory': - case 'death': - case 'defeat': - case 'knockout': - case 'player_knocked': - case 'redeploying': - case 'storm_shrinking': - case 'gulag_start': - case 'gulag_end': - case 'first_half': - case 'second_half': - case 'round_won': - case 'round_lost': - case 'enemy_spotted': - case 'enemy_detected': - case 'interesting_moment': - case 'ender_dragon_spawned': - case 'boss_killed': - case 'wither_spawned': - case 'advancement_made': - case 'first_diamond': - case 'nether_entered': - case 'totem_of_undying_used': - case 'player_revived': - case 'hooked_survivor': - case 'escaped': - case 'tower_destroyed': - case 'glyph_used': - case 'objective_ally': - case 'objective_enemy': - case 'enemy_turret_destroyed': - case 'ally_turret_destroyed': - case 'position_change': - case 'lap_change': - case 'goal': - case 'set_piece': - case 'halftime': - case 'fulltime': - newEvents.push(name); - break; - default: + newEvents.push(name); break; } } diff --git a/app/services/stream-avatar/engine/actions.ts b/app/services/stream-avatar/engine/actions.ts index e5c324c7d39d..76354e025506 100644 --- a/app/services/stream-avatar/engine/actions.ts +++ b/app/services/stream-avatar/engine/actions.ts @@ -1,6 +1,6 @@ import uuid from 'uuid/v4'; import { Properties, PropertyInstance, PropertyMap } from './properties'; -import { Instructions } from './instructions'; +import { Instructions, interpolateInstruction } from './instructions'; import type { TCondition } from './conditions'; import type { GameState } from './game-state'; @@ -15,6 +15,8 @@ export type ActionContext = { switchScene: (id: string) => void; setSourceVisible: (id: string, visible: boolean) => void; saveReplay: () => Promise; + sendInstruction: (instruction: string) => void; + sendSimulationBark: (conditionType: string) => void; }; export type ActionProps = { @@ -148,11 +150,18 @@ export const ActionRegistry = { name: 'comment', label: 'Co-host Comment', properties: {}, - process: async ({ conditionsMet, conditions }: ActionProcessPayload) => { + process: async ({ conditionsMet, conditions, context, state, props }: ActionProcessPayload) => { if (!conditionsMet) return; for (const c of conditions) { - const instruction = Instructions[c.type as keyof typeof Instructions]; - console.log('[AutomationsEngine] co-host.comment', { condition: c.type, instruction }); + if (props?.simulating) { + context.sendSimulationBark(c.type); + } else { + const template = Instructions[c.type as keyof typeof Instructions]; + if (!template) continue; + const instruction = interpolateInstruction(template, state); + console.log('[AutomationsEngine] co-host.comment', { condition: c.type, instruction }); + context.sendInstruction(instruction); + } } }, }, @@ -164,9 +173,10 @@ export const ActionRegistry = { properties: { instruction: new Properties.Text({ label: 'Instruction' }), }, - process: async ({ conditionsMet, props }: ActionProcessPayload) => { - if (!conditionsMet || !props?.instruction) return; + process: async ({ conditionsMet, props, context }: ActionProcessPayload) => { + if (!conditionsMet || !props?.instruction || props?.simulating) return; console.log('[AutomationsEngine] co-host.instruction', { instruction: props.instruction }); + context.sendInstruction(props.instruction); }, }, } as const; diff --git a/app/services/stream-avatar/engine/instructions.ts b/app/services/stream-avatar/engine/instructions.ts index 7cf4ffc7a1c3..143313534e42 100644 --- a/app/services/stream-avatar/engine/instructions.ts +++ b/app/services/stream-avatar/engine/instructions.ts @@ -1,4 +1,5 @@ import type { ConditionType } from './conditions'; +import type { GameState } from './game-state'; const WORD_LIMIT = '8 words max.'; @@ -10,135 +11,135 @@ const withWordLimit = (prompt: string): string => { const perGameInstructions = { // Fortnite 'fortnite.game_started': 'React to the game starting.', - 'fortnite.deployed': 'React to {player} dropping in.', + 'fortnite.deployed': 'React to me dropping in.', 'fortnite.storm_closing': 'Warn about the storm closing in.', 'fortnite.game_ended': 'React to the game ending.', - 'fortnite.low_health': "Panic about {player}'s critically low health.", - 'fortnite.has_shield': 'React to {player} having shield.', - 'fortnite.no_shield': 'Warn {player} they have no shield.', - 'fortnite.victory_royale': "Celebrate {player}'s Victory Royale!", - 'fortnite.player_eliminated': 'React to {player} getting eliminated.', - 'fortnite.player_knocked': 'React to {player} getting knocked.', - 'fortnite.defeat': "React to {player}'s defeat.", + 'fortnite.low_health': 'Panic about my critically low health.', + 'fortnite.has_shield': 'React to me having shield.', + 'fortnite.no_shield': 'React to me having no shield.', + 'fortnite.victory_royale': 'Celebrate my Victory Royale!', + 'fortnite.player_eliminated': 'React to me getting eliminated.', + 'fortnite.player_knocked': 'React to me getting knocked.', + 'fortnite.defeat': 'React to my defeat.', 'fortnite.elimination': 'React to the enemy being eliminated.', - 'fortnite.knocked': 'React to {player} knocking an enemy.', - 'fortnite.elimination_count': 'React to {player} reaching {elimination_count} eliminations.', + 'fortnite.knocked': 'React to me knocking an enemy.', + 'fortnite.elimination_count': 'React to me reaching {elimination_count} eliminations.', 'fortnite.players_remaining': 'Comment on {players_remaining} players remaining.', // PUBG 'pubg.game_started': 'React to the match starting.', - 'pubg.deployed': 'React to {player} parachuting in.', + 'pubg.deployed': 'React to me parachuting in.', 'pubg.storm_closing': 'Warn about the blue zone closing.', 'pubg.game_ended': 'React to the match ending.', - 'pubg.victory': "Celebrate {player}'s chicken dinner!", - 'pubg.player_eliminated': 'React to {player} being eliminated.', - 'pubg.player_knocked': 'React to {player} getting knocked.', - 'pubg.defeat': "React to {player}'s defeat.", + 'pubg.victory': 'Celebrate my chicken dinner!', + 'pubg.player_eliminated': 'React to me being eliminated.', + 'pubg.player_knocked': 'React to me getting knocked.', + 'pubg.defeat': 'React to my defeat.', 'pubg.elimination': 'React to an enemy being eliminated.', - 'pubg.knocked': 'React to {player} knocking an enemy.', - 'pubg.elimination_count': 'React to {player} reaching {elimination_count} eliminations.', + 'pubg.knocked': 'React to me knocking an enemy.', + 'pubg.elimination_count': 'React to me reaching {elimination_count} eliminations.', 'pubg.players_remaining': 'Comment on {players_remaining} players remaining.', // Valorant 'valorant.round_started': 'React to the round starting.', - 'valorant.low_health': "Panic about {player}'s low health.", - 'valorant.victory': "Celebrate {player}'s team winning.", - 'valorant.player_eliminated': 'React to {player} being eliminated.', - 'valorant.defeat': "React to {player}'s team losing.", + 'valorant.low_health': 'Panic about my low health.', + 'valorant.victory': 'Celebrate my team winning.', + 'valorant.player_eliminated': 'React to me being eliminated.', + 'valorant.defeat': 'React to my team losing.', 'valorant.elimination': 'React to an enemy being eliminated.', - 'valorant.elimination_count': 'React to {player} reaching {elimination_count} kills.', + 'valorant.elimination_count': 'React to me reaching {elimination_count} kills.', // Counter-Strike 2 'counter_strike_2.round_started': 'React to the round starting.', 'counter_strike_2.first_half': 'React to the first half starting.', 'counter_strike_2.second_half': 'React to the second half starting.', - 'counter_strike_2.round_won': "React to {player}'s team winning the round.", - 'counter_strike_2.round_lost': "React to {player}'s team losing the round.", + 'counter_strike_2.round_won': 'React to my team winning the round.', + 'counter_strike_2.round_lost': 'React to my team losing the round.', 'counter_strike_2.game_ended': 'React to the match ending.', - 'counter_strike_2.low_health': "Panic about {player}'s low health.", - 'counter_strike_2.victory': "Celebrate {player}'s team winning the match.", - 'counter_strike_2.player_eliminated': 'React to {player} being eliminated.', - 'counter_strike_2.defeat': "React to {player}'s team losing the match.", + 'counter_strike_2.low_health': 'Panic about my low health.', + 'counter_strike_2.victory': 'Celebrate my team winning the match.', + 'counter_strike_2.player_eliminated': 'React to me being eliminated.', + 'counter_strike_2.defeat': 'React to my team losing the match.', 'counter_strike_2.elimination': 'React to an enemy being eliminated.', - 'counter_strike_2.elimination_count': 'React to {player} reaching {elimination_count} kills.', + 'counter_strike_2.elimination_count': 'React to me reaching {elimination_count} kills.', // Warzone - 'warzone.deploy': 'React to {player} deploying in.', - 'warzone.gulag_start': 'React to {player} being sent to the Gulag.', - 'warzone.gulag_end': "React to {player}'s Gulag result.", - 'warzone.spectating': 'React to {player} getting eliminated and spectating.', - 'warzone.redeploying': 'React to {player} redeploying back in.', - 'warzone.victory': "Celebrate {player}'s Warzone victory!", - 'warzone.player_knocked': 'React to {player} getting knocked.', - 'warzone.player_eliminated': 'React to {player} being eliminated.', - 'warzone.defeat': "React to {player}'s defeat.", + 'warzone.deploy': 'React to me deploying in.', + 'warzone.gulag_start': 'React to me being sent to the Gulag.', + 'warzone.gulag_end': 'React to my Gulag result.', + 'warzone.spectating': 'React to me getting eliminated and spectating.', + 'warzone.redeploying': 'React to me redeploying back in.', + 'warzone.victory': 'Celebrate my Warzone victory!', + 'warzone.player_knocked': 'React to me getting knocked.', + 'warzone.player_eliminated': 'React to me being eliminated.', + 'warzone.defeat': 'React to my defeat.', 'warzone.elimination': 'React to an enemy being eliminated.', 'warzone.knockout': 'React to an enemy getting knocked.', - 'warzone.elimination_count': 'React to {player} reaching {elimination_count} eliminations.', + 'warzone.elimination_count': 'React to me reaching {elimination_count} eliminations.', 'warzone.players_remaining': 'Comment on {players_remaining} players remaining.', // Arc Raiders 'arc_raiders.game_start': 'React to the raid starting.', 'arc_raiders.game_end': 'React to the raid ending.', - 'arc_raiders.victory': "Celebrate {player}'s victory!", - 'arc_raiders.defeat': "React to {player}'s defeat.", - 'arc_raiders.player_knocked': 'React to {player} going down.', - 'arc_raiders.player_eliminated': 'React to {player} being eliminated.', + 'arc_raiders.victory': 'Celebrate my victory!', + 'arc_raiders.defeat': 'React to my defeat.', + 'arc_raiders.player_knocked': 'React to me going down.', + 'arc_raiders.player_eliminated': 'React to me being eliminated.', 'arc_raiders.enemy_spotted': 'React to an enemy being spotted.', 'arc_raiders.enemy_detected': 'React to an enemy detected nearby.', 'arc_raiders.interesting_moment': 'React to an interesting moment.', // Call of Duty: Black Ops 6 'black_ops_6.elimination': 'React to an enemy being eliminated.', - 'black_ops_6.victory': "Celebrate {player}'s victory!", - 'black_ops_6.defeat': "React to {player}'s defeat.", - 'black_ops_6.spectating': 'React to {player} now spectating.', + 'black_ops_6.victory': 'Celebrate my victory!', + 'black_ops_6.defeat': 'React to my defeat.', + 'black_ops_6.spectating': 'React to me now spectating.', // Rocket League 'rocket_league.game_start': 'React to the match starting.', 'rocket_league.game_end': 'React to the match ending.', - 'rocket_league.team_scored': "React to {player}'s team scoring a goal.", + 'rocket_league.team_scored': 'React to my team scoring a goal.', 'rocket_league.opponent_scored': 'React to the opponent scoring.', - 'rocket_league.victory': "Celebrate {player}'s Rocket League win!", - 'rocket_league.defeat': "React to {player}'s Rocket League loss.", + 'rocket_league.victory': 'Celebrate my Rocket League win!', + 'rocket_league.defeat': 'React to my Rocket League loss.', // Minecraft 'minecraft.ender_dragon_spawned': 'React to the Ender Dragon spawning.', - 'minecraft.boss_killed': 'Celebrate {player} defeating the boss!', - 'minecraft.wither_spawned': 'React to {player} summoning the Wither.', - 'minecraft.advancement_made': 'React to {player} earning an advancement.', - 'minecraft.first_diamond': 'Celebrate {player} finding diamonds!', - 'minecraft.nether_entered': 'React to {player} entering the Nether.', - 'minecraft.player_eliminated': 'React to {player} dying in Minecraft.', - 'minecraft.low_health': "Panic about {player}'s low health.", - 'minecraft.totem_of_undying_used': 'React to {player} using a Totem of Undying.', + 'minecraft.boss_killed': 'Celebrate me defeating the boss!', + 'minecraft.wither_spawned': 'React to me summoning the Wither.', + 'minecraft.advancement_made': 'React to me earning an advancement.', + 'minecraft.first_diamond': 'Celebrate me finding diamonds!', + 'minecraft.nether_entered': 'React to me entering the Nether.', + 'minecraft.player_eliminated': 'React to me dying in Minecraft.', + 'minecraft.low_health': 'Panic about my low health.', + 'minecraft.totem_of_undying_used': 'React to me using a Totem of Undying.', // Apex Legends 'apex_legends.game_start': 'React to the match starting.', - 'apex_legends.deploy': 'React to {player} jumping from the dropship.', + 'apex_legends.deploy': 'React to me jumping from the dropship.', 'apex_legends.storm_shrinking': 'Warn about the ring closing.', 'apex_legends.game_end': 'React to the match ending.', - 'apex_legends.player_knocked': 'React to {player} getting knocked.', - 'apex_legends.player_revived': 'React to {player} being revived.', - 'apex_legends.player_eliminated': 'React to {player} being eliminated.', - 'apex_legends.victory': "Celebrate {player}'s squad being Champion!", - 'apex_legends.defeat': "React to {player}'s squad being eliminated.", + 'apex_legends.player_knocked': 'React to me getting knocked.', + 'apex_legends.player_revived': 'React to me being revived.', + 'apex_legends.player_eliminated': 'React to me being eliminated.', + 'apex_legends.victory': 'Celebrate my squad being Champion!', + 'apex_legends.defeat': 'React to my squad being eliminated.', 'apex_legends.elimination': 'React to an enemy being eliminated.', - 'apex_legends.knockout': 'React to {player} knocking an enemy.', + 'apex_legends.knockout': 'React to me knocking an enemy.', // Battlefield 6 'battlefield_6.game_start': 'React to the battle starting.', 'battlefield_6.game_end': 'React to the match ending.', - 'battlefield_6.victory': "Celebrate {player}'s team winning!", - 'battlefield_6.defeat': "React to {player}'s team losing.", + 'battlefield_6.victory': 'Celebrate my team winning!', + 'battlefield_6.defeat': 'React to my team losing.', 'battlefield_6.elimination': 'React to an enemy being eliminated.', - 'battlefield_6.player_eliminated': 'React to {player} going down.', + 'battlefield_6.player_eliminated': 'React to me going down.', // Dead by Daylight 'dead_by_daylight.game_start': 'React to the trial beginning.', 'dead_by_daylight.game_end': 'React to the trial ending.', 'dead_by_daylight.victory': 'React to the match ending in victory.', - 'dead_by_daylight.player_eliminated': 'React to {player} being sacrificed.', + 'dead_by_daylight.player_eliminated': 'React to me being sacrificed.', 'dead_by_daylight.elimination': 'React to a survivor being sacrificed.', 'dead_by_daylight.hooked_survivor': 'React to a survivor being hooked.', 'dead_by_daylight.escaped': 'React to a survivor escaping.', @@ -146,76 +147,76 @@ const perGameInstructions = { // Deadlock 'deadlock.game_start': 'React to the match starting.', 'deadlock.game_end': 'React to the match ending.', - 'deadlock.victory': "Celebrate {player}'s team winning!", - 'deadlock.defeat': "React to {player}'s team losing.", + 'deadlock.victory': 'Celebrate my team winning!', + 'deadlock.defeat': 'React to my team losing.', 'deadlock.elimination': 'React to an enemy hero being eliminated.', - 'deadlock.player_eliminated': 'React to {player} being eliminated.', + 'deadlock.player_eliminated': 'React to me being eliminated.', // Dota 2 'dota_2.game_start': 'React to the match starting.', 'dota_2.game_end': 'React to the match ending.', - 'dota_2.victory': "Celebrate {player}'s team destroying the Ancient!", - 'dota_2.defeat': "React to {player}'s team losing.", + 'dota_2.victory': 'Celebrate my team destroying the Ancient!', + 'dota_2.defeat': 'React to my team losing.', 'dota_2.elimination': 'React to a hero kill.', - 'dota_2.player_eliminated': "React to {player}'s hero dying.", + 'dota_2.player_eliminated': 'React to my hero dying.', 'dota_2.tower_destroyed': 'React to a tower being destroyed.', 'dota_2.glyph_used': 'React to the Glyph being activated.', // League of Legends 'league_of_legends.game_start': 'React to minions spawning and the match starting.', 'league_of_legends.game_end': 'React to the match ending.', - 'league_of_legends.victory': "Celebrate {player}'s team destroying the Nexus!", - 'league_of_legends.defeat': "React to {player}'s team losing.", - 'league_of_legends.elimination': 'React to {player} getting a kill.', - 'league_of_legends.player_eliminated': "React to {player}'s champion dying.", - 'league_of_legends.objective_ally': "React to {player}'s team securing an objective.", + 'league_of_legends.victory': 'Celebrate my team destroying the Nexus!', + 'league_of_legends.defeat': 'React to my team losing.', + 'league_of_legends.elimination': 'React to me getting a kill.', + 'league_of_legends.player_eliminated': 'React to my champion dying.', + 'league_of_legends.objective_ally': 'React to my team securing an objective.', 'league_of_legends.objective_enemy': 'React to the enemy stealing an objective.', 'league_of_legends.enemy_turret_destroyed': 'React to an enemy turret falling.', 'league_of_legends.ally_turret_destroyed': 'React to an ally turret being lost.', // Marvel Rivals 'marvel_rivals.game_end': 'React to the match ending.', - 'marvel_rivals.victory': "Celebrate {player}'s team winning!", - 'marvel_rivals.defeat': "React to {player}'s team losing.", + 'marvel_rivals.victory': 'Celebrate my team winning!', + 'marvel_rivals.defeat': 'React to my team losing.', 'marvel_rivals.elimination': 'React to a hero being eliminated.', - 'marvel_rivals.player_eliminated': "React to {player}'s hero being eliminated.", + 'marvel_rivals.player_eliminated': 'React to my hero being eliminated.', // Overwatch 2 'overwatch_2.round_start': 'React to the round starting.', 'overwatch_2.round_end': 'React to the round ending.', - 'overwatch_2.victory': "Celebrate {player}'s team winning!", - 'overwatch_2.defeat': "React to {player}'s team losing.", + 'overwatch_2.victory': 'Celebrate my team winning!', + 'overwatch_2.defeat': 'React to my team losing.', 'overwatch_2.elimination': 'React to an enemy being eliminated.', - 'overwatch_2.player_eliminated': 'React to {player} being eliminated.', + 'overwatch_2.player_eliminated': 'React to me being eliminated.', // Rainbow Six Siege 'rainbow_six_siege.round_start': 'React to the preparation phase starting.', 'rainbow_six_siege.action_phase': 'React to the action phase beginning.', 'rainbow_six_siege.round_end': 'React to the round ending.', - 'rainbow_six_siege.victory': "React to {player}'s team winning the round.", - 'rainbow_six_siege.defeat': "React to {player}'s team losing the round.", + 'rainbow_six_siege.victory': 'React to my team winning the round.', + 'rainbow_six_siege.defeat': 'React to my team losing the round.', 'rainbow_six_siege.elimination': 'React to an enemy operator being eliminated.', - 'rainbow_six_siege.player_eliminated': 'React to {player} being eliminated.', + 'rainbow_six_siege.player_eliminated': 'React to me being eliminated.', // War Thunder - 'war_thunder.elimination': 'React to {player} destroying an enemy.', + 'war_thunder.elimination': 'React to me destroying an enemy.', // Marathon 'marathon.game_start': 'React to the extraction mission starting.', 'marathon.game_end': 'React to the match ending.', - 'marathon.victory': 'Celebrate {player} successfully exfiltrating!', - 'marathon.defeat': 'React to {player} being eliminated before extraction.', - 'marathon.player_knocked': 'React to {player} going down.', - 'marathon.player_eliminated': 'React to {player} being eliminated.', + 'marathon.victory': 'Celebrate me successfully exfiltrating!', + 'marathon.defeat': 'React to me being eliminated before extraction.', + 'marathon.player_knocked': 'React to me going down.', + 'marathon.player_eliminated': 'React to me being eliminated.', 'marathon.elimination': 'React to an enemy runner being eliminated.', 'marathon.knockout': 'React to a runner being knocked down.', // F1 25 'f1_25.game_start': 'React to the race starting.', - 'f1_25.game_end': 'React to {player} crossing the chequered flag.', - 'f1_25.victory': 'Celebrate {player} winning the race in P1!', - 'f1_25.position_change': "React to {player}'s position changing on track.", - 'f1_25.lap_change': 'React to {player} starting a new lap.', + 'f1_25.game_end': 'React to me crossing the chequered flag.', + 'f1_25.victory': 'Celebrate me winning the race in P1!', + 'f1_25.position_change': 'React to my position changing on track.', + 'f1_25.lap_change': 'React to me starting a new lap.', // EA Sports FC 26 'ea_sports_fc_26.game_start': 'React to the match kicking off.', @@ -233,18 +234,18 @@ const perGameInstructions = { // Forza Horizon 6 'forza_horizon_6.game_start': 'React to the race starting.', - 'forza_horizon_6.game_end': 'React to {player} finishing the race.', - 'forza_horizon_6.position_change': "React to {player}'s race position changing.", - 'forza_horizon_6.lap_change': 'React to {player} starting a new lap.', - 'forza_horizon_6.great_drift': 'React to {player} pulling off an epic drift.', - 'forza_horizon_6.great_air': 'React to {player} catching massive air.', - 'forza_horizon_6.great_skill_chain': 'React to {player} landing a great skill chain.', + 'forza_horizon_6.game_end': 'React to me finishing the race.', + 'forza_horizon_6.position_change': 'React to my race position changing.', + 'forza_horizon_6.lap_change': 'React to me starting a new lap.', + 'forza_horizon_6.great_drift': 'React to me pulling off an epic drift.', + 'forza_horizon_6.great_air': 'React to me catching massive air.', + 'forza_horizon_6.great_skill_chain': 'React to me landing a great skill chain.', // Enshrouded - 'enshrouded.player_eliminated': 'React to {player} being eliminated in Enshrouded.', - 'enshrouded.level_up': 'Celebrate {player} leveling up!', - 'enshrouded.soul_discovered': 'React to {player} discovering a soul.', - 'enshrouded.quest_update': "React to {player}'s quest being updated.", + 'enshrouded.player_eliminated': 'React to me being eliminated in Enshrouded.', + 'enshrouded.level_up': 'Celebrate me leveling up!', + 'enshrouded.soul_discovered': 'React to me discovering a soul.', + 'enshrouded.quest_update': 'React to my quest being updated.', } as const; export type InstructionType = keyof typeof perGameInstructions; @@ -253,3 +254,9 @@ export type TInstruction = InstructionType; export const Instructions = Object.fromEntries( Object.entries(perGameInstructions).map(([type, prompt]) => [type, withWordLimit(prompt)]), ) as Record; + +export function interpolateInstruction(instruction: string, state: GameState): string { + return instruction + .replace('{elimination_count}', String(state.eliminations)) + .replace('{players_remaining}', String(state.playersRemaining)); +} From 06507e6ae50cbf89608d1721544e84165bea4bae Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Fri, 19 Jun 2026 14:58:13 -0700 Subject: [PATCH 10/79] feat(sources): add methods to remove and set filter visibility in SourcesModule --- app/services/platform-apps/api/modules/sources.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/services/platform-apps/api/modules/sources.ts b/app/services/platform-apps/api/modules/sources.ts index 3e1c7f4a3da1..8221be28f5e1 100644 --- a/app/services/platform-apps/api/modules/sources.ts +++ b/app/services/platform-apps/api/modules/sources.ts @@ -282,4 +282,14 @@ export class SourcesModule extends Module { ) { this.sourceFiltersService.add(sourceId, filterType, filterName, settings); } + + @apiMethod() + removeFilter(ctx: IApiContext, sourceId: string, filterName: string) { + this.sourceFiltersService.remove(sourceId, filterName); + } + + @apiMethod() + setFilterEnabled(ctx: IApiContext, sourceId: string, filterName: string, enabled: boolean) { + this.sourceFiltersService.setVisibility(sourceId, filterName, enabled); + } } From a54f25871bb5d9bee78510d3afd353c6bf018c6b Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Fri, 19 Jun 2026 16:29:23 -0700 Subject: [PATCH 11/79] feat(automations): add ResizeObserver to Scrollable for dynamic scrollbar updates --- app/components-react/shared/Scrollable.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/components-react/shared/Scrollable.tsx b/app/components-react/shared/Scrollable.tsx index db1ae300c9a5..8306b5240ebb 100644 --- a/app/components-react/shared/Scrollable.tsx +++ b/app/components-react/shared/Scrollable.tsx @@ -38,6 +38,20 @@ export default function Scrollable(initialProps: IScrollableProps & HTMLAttribut return () => cancelAnimationFrame(id); }, []); + useEffect(() => { + // autoUpdate watches the host element (fixed height via CSS) and won't fire + // when async content grows past the viewport. Observe the lib's own content + // element (grows with the children) so the scrollbar appears once data loads. + const instance = osRef.current?.osInstance(); + const content = instance?.getElements()?.content as HTMLElement | undefined; + if (!content) return; + const ro = new ResizeObserver(() => { + osRef.current?.osInstance()?.update(true); + }); + ro.observe(content); + return () => ro.disconnect(); + }, []); + function onOverflowChanged(ev?: { yScrollable: boolean }) { if (!ev) return; if (p.snapToWindowEdge && ev.yScrollable) { From 9a37480e151fd7ac7515d146739dd7255498e426 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 24 Jun 2026 12:58:24 -0700 Subject: [PATCH 12/79] wip(UX): working on automations UX --- .../AutomationEditor.tsx | 298 ++++++++++-------- .../EditAutomations.m.less | 143 +++++++-- .../EditAutomations.tsx | 137 ++++++-- .../PreMadeAutomations.m.less | 142 +++++++++ .../PreMadeAutomations.tsx | 202 ++++++++++++ app/i18n/en-US/stream-avatar-automations.json | 45 ++- 6 files changed, 788 insertions(+), 179 deletions(-) create mode 100644 app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less create mode 100644 app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index c773246c17ee..121dfd692a95 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -1,5 +1,6 @@ import React, { CSSProperties, useEffect, useState } from 'react'; -import { Select, Input } from 'antd'; +import { Button, Select, Input, Slider } from 'antd'; +import { Properties } from 'services/stream-avatar/engine/properties'; import { ReactSortable } from 'react-sortablejs'; import uuid from 'uuid/v4'; import { ModalLayout } from 'components-react/shared/ModalLayout'; @@ -26,8 +27,6 @@ const errorTextStyle: CSSProperties = { fontSize: '12px', }; -// Drag-and-drop reordering needs a stable key per row that survives reorders and -// inserts (using the array index would make React/Sortable lose track on move). interface ActionRow { id: string; action: ExportedAction; @@ -37,8 +36,6 @@ function makeRow(action: ExportedAction): ActionRow { return { id: uuid(), action: withActionDefaults(action) }; } -// Height of a single control line, so the grip and +/- icons align with the -// action's primary input regardless of any extra rows (checkbox, slider) below. const CONTROL_HEIGHT = '32px'; const dragHandleStyle: CSSProperties = { @@ -53,25 +50,13 @@ const gripCellStyle: CSSProperties = { height: CONTROL_HEIGHT, }; -const actionsCellStyle: CSSProperties = { +const trashCellStyle: CSSProperties = { display: 'flex', - justifyContent: 'flex-end', alignItems: 'center', - gap: '10px', height: CONTROL_HEIGHT, -}; - -const iconButtonStyle: CSSProperties = { cursor: 'pointer', color: 'var(--icon-active)', - fontSize: '16px', -}; - -const labelStyle: CSSProperties = { - display: 'block', - marginBottom: '4px', - fontWeight: 600, - color: 'var(--title)', + fontSize: '14px', }; const ACTION_OPTIONS = Object.entries(ActionRegistry).map(([type, def]) => ({ @@ -93,24 +78,20 @@ function getConditionOptions(gameId: string) { interface ActionEditorProps { action: ExportedAction; index: number; - isFirst: boolean; scenes: { id: string; name: string }[]; sources: { id: string; name: string }[]; errors?: Record; onChange: (index: number, action: ExportedAction) => void; - onInsert: (index: number) => void; onRemove: (index: number) => void; } function ActionEditor({ action, index, - isFirst, scenes, sources, errors, onChange, - onInsert, onRemove, }: ActionEditorProps) { function setType(type: ActionType) { @@ -131,8 +112,6 @@ function ActionEditor({ const sourceName = props.source?.name ?? ''; const sourceMissing = !!sourceName && !sources.some(s => s.name === sourceName); - // The action's inline control (column 3), stacked vertically when it has more - // than one row (e.g. a source select plus its checkbox). function renderControl() { switch (action.type) { case 'common.switch_to_scene': { @@ -259,11 +238,11 @@ function ActionEditor({
@@ -275,9 +254,9 @@ function ActionEditor({
setSelectedGame(val)} - style={{ flex: '0 0 auto' }} + placeholder={$t('Select a Game')} options={GAME_OPTIONS} dropdownMatchSelectWidth={false} /> setFilterGame(val)} + options={[{ label: $t('All game automations'), value: '' }, ...GAME_FILTER_OPTIONS]} + style={{ width: 200 }} + /> + + + +
+
+ {loading &&
{$t('Loading...')}
} {!loading && automations.length === 0 && ( - +
+
+

{$t("You don't have Automations set up yet.")}

+

+ {$t( + 'Automatically trigger on stream effects including visual effects, transitions, sounds, agent commentary (and more) in response to gameplay events such as kills, wins, knocks, deaths, and much more. See supported games.', + )} +

+
+ + +
+
+ )} + + {!loading && automations.length > 0 && filtered.length === 0 && ( +
{$t('No automations match the selected filter.')}
)} - {!loading && automations.length > 0 && ( + {!loading && filtered.length > 0 && ( - - - - + + + + - {automations.map(automation => { + {filtered.map(automation => { const issues = validateAutomation(automation, { scenes, sources }); return ( @@ -158,9 +259,9 @@ export default function EditAutomations() { {automation.conditions.map((c, i) => { const game = conditionGame(c); return game ? ( - + {game} - + ) : null; })} diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less new file mode 100644 index 000000000000..1b4148b04698 --- /dev/null +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less @@ -0,0 +1,142 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0 8px; +} + +.gameIcon { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--icon-active); + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: #000; + margin-bottom: 12px; +} + +.title { + margin: 0 0 20px; + font-size: 20px; + font-weight: 700; + color: var(--title); + text-align: center; +} + +.featured { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + margin-bottom: 16px; +} + +.navBtn { + background: none; + border: none; + color: var(--paragraph); + font-size: 20px; + cursor: pointer; + padding: 8px; + flex-shrink: 0; + border-radius: 4px; + + &:hover { + color: var(--title); + background: var(--section); + } +} + +.card { + flex: 1; + min-width: 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border); +} + +.preview { + width: 100%; + height: 200px; + display: flex; + align-items: center; + justify-content: center; +} + +.previewLabel { + font-size: 16px; + font-weight: 700; + color: rgba(255, 255, 255, 0.5); + letter-spacing: 2px; + text-transform: uppercase; +} + +.infoRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--section); + gap: 12px; +} + +.itemTitle { + font-size: 15px; + font-weight: 700; + color: var(--title); + margin-bottom: 2px; +} + +.itemDesc { + font-size: 12px; + color: var(--paragraph); +} + +.thumbnailStrip { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.thumbnail { + width: 72px; + height: 48px; + border-radius: 4px; + border: 2px solid transparent; + cursor: pointer; + padding: 0; + flex-shrink: 0; + + &:hover { + border-color: var(--icon-active); + } +} + +.thumbnailActive { + border-color: var(--icon-active); +} + +.dots { + display: flex; + gap: 6px; + align-items: center; +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--border); + cursor: pointer; + + &:hover { + background: var(--paragraph); + } +} + +.dotActive { + background: var(--icon-active); +} diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx new file mode 100644 index 000000000000..7548479eea13 --- /dev/null +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react'; +import { Button, Switch } from 'antd'; +import { ModalLayout } from 'components-react/shared/ModalLayout'; +import { Services } from 'components-react/service-provider'; +import { $t } from 'services/i18n'; +import type { ConditionType } from 'services/stream-avatar/engine/conditions'; +import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; +import styles from './PreMadeAutomations.m.less'; + +interface PreMadeItem { + title: string; + description: string; + gameName: string; + color: string; + automation: Omit; +} + +// ponytail: static seed data — replace with API fetch when catalog exists +const PRE_MADE: PreMadeItem[] = [ + { + title: 'Victory Royale', + description: 'Co-host comments on each win.', + gameName: 'Fortnite', + color: '#1a3a5c', + automation: { + description: 'Victory Royale', + conditions: [{ type: 'fortnite.victory_royale' as ConditionType }], + actions: [{ type: 'co-host.comment' }], + enabled: true, + }, + }, + { + title: 'Enemy Knocked', + description: 'Co-host reacts when you knock an enemy.', + gameName: 'Fortnite', + color: '#2d4a1e', + automation: { + description: 'Enemy Knocked', + conditions: [{ type: 'fortnite.knocked' as ConditionType }], + actions: [{ type: 'co-host.comment' }], + enabled: true, + }, + }, + { + title: 'Defeat', + description: 'Co-host reacts to each defeat.', + gameName: 'Fortnite', + color: '#3a1a1a', + automation: { + description: 'Defeat', + conditions: [{ type: 'fortnite.defeat' as ConditionType }], + actions: [{ type: 'co-host.comment' }], + enabled: true, + }, + }, + { + title: 'Round Won', + description: 'Co-host comments on every round win.', + gameName: 'Valorant', + color: '#2a1a3a', + automation: { + description: 'Round Won', + conditions: [{ type: 'valorant.victory' as ConditionType }], + actions: [{ type: 'co-host.comment' }], + enabled: true, + }, + }, + { + title: 'Player Eliminated', + description: 'Co-host reacts to each elimination.', + gameName: 'Valorant', + color: '#1a1a3a', + automation: { + description: 'Player Eliminated', + conditions: [{ type: 'valorant.player_eliminated' as ConditionType }], + actions: [{ type: 'co-host.comment' }], + enabled: true, + }, + }, +]; + +interface Props { + onClose: () => void; +} + +export default function PreMadeAutomations({ onClose }: Props) { + const { AutomationsService } = Services; + const [currentIndex, setCurrentIndex] = useState(0); + const [enabledSet, setEnabledSet] = useState>(new Set()); + const [saving, setSaving] = useState(false); + + function toggleItem(index: number) { + setEnabledSet(prev => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + } + + function goTo(index: number) { + setCurrentIndex(index); + } + + function prev() { + setCurrentIndex(i => (i > 0 ? i - 1 : PRE_MADE.length - 1)); + } + + function next() { + setCurrentIndex(i => (i < PRE_MADE.length - 1 ? i + 1 : 0)); + } + + async function handleComplete() { + setSaving(true); + try { + for (const index of enabledSet) { + await AutomationsService.actions.create(PRE_MADE[index].automation); + } + onClose(); + } finally { + setSaving(false); + } + } + + const current = PRE_MADE[currentIndex]; + + const footer = ( + <> + + + + ); + + return ( + +
+
+ +
+ +

{$t('Select Pre-made Automation')}

+ +
+ + +
+
+ {current.gameName} +
+
+
+
{current.title}
+
{current.description}
+
+ toggleItem(currentIndex)} + /> +
+
+ + +
+ +
+ {PRE_MADE.map((item, i) => ( +
+ +
+ {PRE_MADE.map((_, i) => ( + goTo(i)} + /> + ))} +
+
+
+ ); +} diff --git a/app/i18n/en-US/stream-avatar-automations.json b/app/i18n/en-US/stream-avatar-automations.json index a355c2f5278c..78b209348dad 100644 --- a/app/i18n/en-US/stream-avatar-automations.json +++ b/app/i18n/en-US/stream-avatar-automations.json @@ -1,10 +1,39 @@ { + "Automations": "Automations", + "Automatically trigger stream effects in response to gameplay events": "Automatically trigger stream effects in response to gameplay events", + "Automatically trigger on stream effects in response to gameplay events.": "Automatically trigger on stream effects in response to gameplay events.", + "Filter by": "Filter by", + "All game automations": "All game automations", + "Add New": "Add New", + "Add New Automation": "Add New Automation", + "Select from Pre-made": "Select from Pre-made", + "Select Pre-made Automation": "Select Pre-made Automation", + "DESCRIPTION": "DESCRIPTION", + "TRIGGER": "TRIGGER", + "REACTION": "REACTION", + "GAME": "GAME", + "No automations match the selected filter.": "No automations match the selected filter.", + "You don't have Automations set up yet.": "You don't have Automations set up yet.", + "Automatically trigger on stream effects including visual effects, transitions, sounds, agent commentary (and more) in response to gameplay events such as kills, wins, knocks, deaths, and much more. See supported games.": "Automatically trigger on stream effects including visual effects, transitions, sounds, agent commentary (and more) in response to gameplay events such as kills, wins, knocks, deaths, and much more. See supported games.", + "Create Custom": "Create Custom", + "Use Pre-made Automations": "Use Pre-made Automations", + "Adding...": "Adding...", + "View Automation Templates": "View Automation Templates", + "Add Trigger": "Add Trigger", + "Set the condition that activates this automation": "Set the condition that activates this automation", + "Select a Game": "Select a Game", + "Select a Condition": "Select a Condition", + "Add Reaction(s)": "Add Reaction(s)", + "Add action(s) to perform after the trigger": "Add action(s) to perform after the trigger", + "Add Reaction": "Add Reaction", + "Select an Action...": "Select an Action...", + "Remove reaction": "Remove reaction", "Edit Automations.": "Edit Automations.", - "New Automation": "New Automation", "Edit Automation": "Edit Automation", "Create Automation": "Create Automation", + "Save Automation": "Save Automation", + "Saving...": "Saving...", "Delete this automation?": "Delete this automation?", - "You don't have any automations yet.": "You don't have any automations yet.", "Loading...": "Loading...", "Test automation": "Test automation", "Enabled": "Enabled", @@ -12,20 +41,8 @@ "(unknown)": "(unknown)", "(no description)": "(no description)", "Description": "Description", - "When": "When", - "Do": "Do", - "Game": "Game", "Edit": "Edit", "Delete": "Delete", - "Back": "Back", - "Saving...": "Saving...", - "Save Changes": "Save Changes", - "When (Condition)": "When (Condition)", - "Do (Actions)": "Do (Actions)", - "No conditions available": "No conditions available", - "+ Add Action": "+ Add Action", - "Insert a new action after this one": "Insert a new action after this one", - "Remove this action": "Remove this action", "Drag to reorder": "Drag to reorder", "seconds": "seconds", "e.g. Victory Royale reaction": "e.g. Victory Royale reaction", From b375b39111542e68e533c998bb430d0ae87cc853 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 24 Jun 2026 13:59:34 -0700 Subject: [PATCH 13/79] feat(automations): update styles and functionality for automation components --- .../AutomationEditor.m.less | 10 + .../AutomationEditor.tsx | 15 +- .../EditAutomations.m.less | 6 +- .../PreMadeAutomations.m.less | 62 +++--- .../PreMadeAutomations.tsx | 187 ++++++++++++------ .../stream-avatar/automations-service.ts | 6 +- 6 files changed, 183 insertions(+), 103 deletions(-) create mode 100644 app/components-react/windows/stream-avatar-automations/AutomationEditor.m.less diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.m.less b/app/components-react/windows/stream-avatar-automations/AutomationEditor.m.less new file mode 100644 index 000000000000..bb729135e05e --- /dev/null +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.m.less @@ -0,0 +1,10 @@ +@import '../../../styles/colors'; + +.trashIcon { + color: @light-5; + cursor: pointer; + + &:hover { + color: var(--title); + } +} diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index 121dfd692a95..1c0c00aee15a 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -20,6 +20,7 @@ import type { ExportedActionProps, } from 'services/stream-avatar/engine/actions'; import { TextInput, CheckboxInput, SliderInput } from 'components-react/shared/inputs'; +import styles from './AutomationEditor.m.less'; const errorTextStyle: CSSProperties = { margin: '4px 0 0', @@ -54,8 +55,6 @@ const trashCellStyle: CSSProperties = { display: 'flex', alignItems: 'center', height: CONTROL_HEIGHT, - cursor: 'pointer', - color: 'var(--icon-active)', fontSize: '14px', }; @@ -241,7 +240,7 @@ function ActionEditor({ gridTemplateColumns: 'auto 1.2fr minmax(0, 1fr) auto', gap: '12px', alignItems: 'start', - padding: '12px 0', + padding: '8px 0', borderBottom: '1px solid var(--border)', }} > @@ -264,7 +263,12 @@ function ActionEditor({ {renderControl()} -
onRemove(index)} title={$t('Remove reaction')}> +
onRemove(index)} + title={$t('Remove reaction')} + >
@@ -501,7 +505,6 @@ export default function AutomationEditor({ initial, onClose, onViewTemplates }: onChange={val => setSelectedGame(val)} placeholder={$t('Select a Game')} options={GAME_OPTIONS} - dropdownMatchSelectWidth={false} /> `s are sorted **at the point of display** rather than by +reordering the source registries, so new games/conditions sort automatically: + +- `GAME_OPTIONS` (`AutomationEditor.tsx`) → `.sort((a, b) => a.name.localeCompare(b.name))` + (by display name, e.g. Apex Legends, Battlefield 6, Call of Duty: Black Ops 6, …). +- `getConditionOptions(gameId)` → `.sort((a, b) => a.label.localeCompare(b.label))` + (by condition label, within the selected game). + +`localeCompare` gives human-alphabetical order. The default-selected condition is +now `conditionOptions[0]` (alphabetically first). `GAME_NAMES` and the per-game +condition files keep their authoring order — ordering is purely a render concern. + +## 6. Internationalisation (i18n) + +Desktop's `$t` is VueI18n. **en-US is bundled** from `app/i18n/fallback.ts`, NOT +read from the `en-US/` folder at runtime — so a new dictionary file only takes +effect once it's `require`d in `fallback.ts`. VueI18n only interpolates `%{}` for +keys present in the dictionary; new unregistered strings render the placeholder +literally (this was an earlier visible bug with `%{name}`). + +Done: +- Added **`app/i18n/en-US/stream-avatar-automations.json`** — all 47 user-facing + strings of the feature (launcher, list, editor, validation), with `%{name}` / + `%{max}` placeholders preserved. +- Registered it in **`app/i18n/fallback.ts`**. + +Effect: `%{}` now interpolates natively for en-US, and since `en-US` is the +`fallbackLocale`, other locales inherit the interpolatable string before they're +translated. The manual `t()` helper in `validation.ts` is now redundant but left +in as harmless defensive code. + +--- + +## 7. Files touched (quick index) + +**Created** +- `app/services/stream-avatar/engine/conditions.ts` — flat merge of `conditions/` directory (see §5c round 2). +- `app/services/stream-avatar/engine/instructions.ts` — flat merge of `instructions/` directory (see §5c round 2). +- `app/services/stream-avatar/engine/validation.ts` +- `app/i18n/en-US/stream-avatar-automations.json` +- `AUTOMATIONS_MIGRATION.md` (this file) + +**Deleted** +- `app/services/stream-avatar/engine/conditions/` (entire directory — 27 files including `index.ts`, `shared.ts`, and 25 per-game files). +- `app/services/stream-avatar/engine/instructions/` (entire directory — 26 files including `index.ts` and 25 per-game files). + +**Edited** +- `app/services/stream-avatar/engine/actions.ts` — `defaultExportedProps`, `withActionDefaults`, removed debug log, `as never` fix. +- `app/services/stream-avatar/automations-engine-service.ts` — `simulateAutomation(id)`. +- `app/components-react/windows/stream-avatar-automations/EditAutomations.tsx` — validation error icon, play/Test button + `Spin` + `simulatingId`, live scenes/sources. +- `app/components-react/windows/stream-avatar-automations/EditAutomations.m.less` — `.errorIcon`, `.disabledIcon`. +- `app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx` — shared validation, inline errors/red borders, removed Enabled control, `withActionDefaults` wiring, **drag-and-drop reorder + insert-between** (`react-sortablejs`, `ActionRow` stable ids), **alphabetical game/condition sorting** (see §5d). +- `app/i18n/fallback.ts` — registered the automations dictionary. + +--- + +## 8. Known gaps / next steps + +1. **Registry labels not translatable.** Action/condition/game labels come from + `ActionRegistry`, `Conditions`, `GAME_NAMES` and are rendered directly (not via + `$t`), so they stay English regardless of locale. Wrapping them is a larger + pass: wrap at each render point (`ACTION_OPTIONS` / `summarizeActions` / + `conditionLabel` / `GAME_OPTIONS`) and add the label strings to the dictionary. +2. **Full CLI build not yet run clean.** The shell safety classifier was + intermittently unavailable, so `tsc` / `yarn compile` wasn't confirmed end-to-end. + Run `npx tsc --noEmit` from `desktop/` (or `yarn compile` in CI) to confirm the + §5c round-2 flat-file consolidation is clean. The `satisfies Record` + on `perGameInstructions` provides a compile-time guard that every condition key + has a matching instruction. +3. **Diagnostic `console.log`s** remain in `automations-service.ts` (fetchAll raw + response logging — see `.claude/plans/immutable-prancing-pancake.md`), + `agent-socket-service.ts`, and backend controllers/repos. Strip once the socket + path is confirmed working. `console.warn` lines in the engine are intentional. +4. **Non-destructive simulation** mode (optional) — see §4 note. + +--- + +## 9. How to resume + +1. Open the list modal via the **Edit Automations.** button in `SceneSelector`. +2. Verify: validation icons appear for automations referencing deleted + scenes/sources; the editor blocks invalid saves and shows inline errors; the + play button runs + reverts an automation with an accurate spinner; a + default-position `wait_for_ms` saves `duration: 5000`. +3. Pick up from §8 — most likely the registry-label i18n pass and a clean + `yarn compile`, then the `console.log` cleanup once the backend round-trip is + confirmed. From 5c98ecf97bde28f2c023b3cdf7028ad1357473d3 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 8 Jul 2026 09:51:59 -0700 Subject: [PATCH 19/79] feat(automations): integrate Intelligent Streaming Agent app detection and installation prompts in automation editor --- .../hooks/useAgentAppInstalled.ts | 61 ++++++++ app/components-react/pages/AILanding.tsx | 35 +---- .../AutomationEditor.tsx | 146 +++++++++++++++++- .../EditAutomations.tsx | 8 +- app/services/hosts.ts | 4 +- .../stream-avatar/engine/validation.ts | 16 ++ 6 files changed, 228 insertions(+), 42 deletions(-) create mode 100644 app/components-react/hooks/useAgentAppInstalled.ts diff --git a/app/components-react/hooks/useAgentAppInstalled.ts b/app/components-react/hooks/useAgentAppInstalled.ts new file mode 100644 index 000000000000..df6afe9a4fd3 --- /dev/null +++ b/app/components-react/hooks/useAgentAppInstalled.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { Services } from 'components-react/service-provider'; +import { EMenuItemKey } from 'services/side-nav'; +import { getOS, OS } from 'util/operating-systems'; + +export const AGENT_APP_STORE_ID = '7643'; +export const AGENT_APP_ID = '93125d1c33'; + +/** + * Tracks whether the Intelligent Streaming Agent (co-host) platform app is + * installed and enabled, and exposes helpers to install or (re-)enable it — + * the same detection/redirect logic AILanding.tsx uses for its co-host + * feature card. Installed and enabled are tracked separately since a user + * can install the app but later disable it from Settings > Installed Apps. + */ +export function useAgentAppInstalled() { + const { NavigationService, PlatformAppsService, SideNavService } = Services; + const [isInstalled, setIsInstalled] = useState(false); + const [isEnabled, setIsEnabled] = useState(false); + + useEffect(() => { + let active = true; + let processing = false; + + void loadProductionApps(); + + return () => { + active = false; + }; + + async function loadProductionApps() { + if (getOS() !== OS.Windows) return; + if (processing) return; + + processing = true; + setIsInstalled(false); + setIsEnabled(false); + await PlatformAppsService.actions.return.loadProductionApps(); + + if (!active) return; + + const app = PlatformAppsService.views.productionApps.find(a => a.id === AGENT_APP_ID); + setIsInstalled(!!app); + setIsEnabled(!!app?.enabled); + processing = false; + } + }, []); + + async function installAgent() { + await PlatformAppsService.actions.return.refreshProductionApps(); + NavigationService.actions.navigate('PlatformAppStore', { appId: AGENT_APP_STORE_ID }); + SideNavService.actions.setCurrentMenuItem(EMenuItemKey.AppStore); + } + + function enableAgent() { + PlatformAppsService.actions.setEnabled(AGENT_APP_ID, true); + setIsEnabled(true); + } + + return { isInstalled, isEnabled, installAgent, enableAgent }; +} diff --git a/app/components-react/pages/AILanding.tsx b/app/components-react/pages/AILanding.tsx index e9e299046b45..0753449d58b5 100644 --- a/app/components-react/pages/AILanding.tsx +++ b/app/components-react/pages/AILanding.tsx @@ -1,5 +1,6 @@ import cx from 'classnames'; import { useVuex } from 'components-react/hooks'; +import { AGENT_APP_ID, useAgentAppInstalled } from 'components-react/hooks/useAgentAppInstalled'; import { useRealmObject } from 'components-react/hooks/realm'; import { Services } from 'components-react/service-provider'; import { SwitchInput } from 'components-react/shared/inputs'; @@ -13,7 +14,6 @@ import { IOverlayCollectionParams, TOverlayType } from 'services/user'; import { $i } from 'services/utils'; import { WidgetDisplayData } from 'services/widgets'; import { WidgetType } from 'services/widgets/widgets-data'; -import { getOS, OS } from 'util/operating-systems'; import styles from './AILanding.m.less'; interface FeatureAction { @@ -31,9 +31,6 @@ interface FeatureProps { actions?: FeatureAction | FeatureAction[]; } -const AGENT_APP_STORE_ID = '7643'; -const AGENT_APP_ID = '93125d1c33'; - function AIFeature(props: FeatureProps) { const actionsObj = props.actions ?? []; const actions = Array.isArray(actionsObj) ? actionsObj : [actionsObj]; @@ -101,33 +98,9 @@ export default function AILanding() { [sources], ); - const [isAgentAppInstalled, setIsAgentAppInstalled] = useState(false); + const { isInstalled: isAgentAppInstalled, installAgent } = useAgentAppInstalled(); useEffect(() => { - let active = true; - let processing = false; - - void loadProductionApps(); trackEvent('impression'); - - return () => { - active = false; - }; - - async function loadProductionApps() { - if (getOS() !== OS.Windows) return; - if (processing) return; - - processing = true; - setIsAgentAppInstalled(false); - await PlatformAppsService.actions.return.loadProductionApps(); - - // Bail early if the component unmounted while we were loading. - if (!active) return; - - const installed = PlatformAppsService.views.productionApps.some(a => a.id === AGENT_APP_ID); - setIsAgentAppInstalled(installed); - processing = false; - } }, []); function onToggleAiClick(isEnabled?: boolean) { @@ -188,9 +161,7 @@ export default function AILanding() { async function onInstallAgentClick() { trackEvent('install-agent'); - await PlatformAppsService.actions.return.refreshProductionApps(); - NavigationService.actions.navigate('PlatformAppStore', { appId: AGENT_APP_STORE_ID }); - SideNavService.actions.setCurrentMenuItem(EMenuItemKey.AppStore); + await installAgent(); } async function onLaunchAgentClick() { diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index b1c1a16f1917..9fbbc77dd1c1 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -1,10 +1,12 @@ import React, { CSSProperties, useEffect, useState } from 'react'; -import { Button, Select, Input, Slider } from 'antd'; +import { Button, Select, Input, Slider, Tag } from 'antd'; import { Properties } from 'services/stream-avatar/engine/properties'; import { ReactSortable } from 'react-sortablejs'; import uuid from 'uuid/v4'; import { ModalLayout } from 'components-react/shared/ModalLayout'; +import { alertAsync } from 'components-react/modals'; import { useVuex } from 'components-react/hooks'; +import { useAgentAppInstalled } from 'components-react/hooks/useAgentAppInstalled'; import { Services } from 'components-react/service-provider'; import { $t } from 'services/i18n'; import { Conditions, GAME_NAMES, ConditionType } from 'services/stream-avatar/engine/conditions'; @@ -58,10 +60,25 @@ const trashCellStyle: CSSProperties = { fontSize: '14px', }; -const ACTION_OPTIONS = Object.entries(ActionRegistry).map(([type, def]) => ({ - label: def.label, - value: type as ActionType, -})); +function requiresAgentApp(type: ActionType): boolean { + return ActionRegistry[type]?.group === 'co-host'; +} + +function getActionOptions() { + return Object.entries(ActionRegistry).map(([type, def]) => ({ + label: requiresAgentApp(type as ActionType) ? ( + + {def.label}{' '} + + {$t('Requires ISA App')} + + + ) : ( + def.label + ), + value: type as ActionType, + })); +} const GAME_OPTIONS = Object.entries(GAME_NAMES) .map(([id, name]) => ({ label: name, value: id })) @@ -80,6 +97,10 @@ interface ActionEditorProps { scenes: { id: string; name: string }[]; sources: { id: string; name: string }[]; errors?: Record; + isAgentInstalled: boolean; + isAgentEnabled: boolean; + onInstallAgent: () => Promise; + onEnableAgent: () => void; onChange: (index: number, action: ExportedAction) => void; onRemove: (index: number) => void; } @@ -90,10 +111,65 @@ function ActionEditor({ scenes, sources, errors, + isAgentInstalled, + isAgentEnabled, + onInstallAgent, + onEnableAgent, onChange, onRemove, }: ActionEditorProps) { + const agentReady = isAgentInstalled && isAgentEnabled; + + async function requireAgentApp() { + if (!isAgentInstalled) { + await alertAsync({ + type: 'confirm', + title: $t('Intelligent Streaming Agent Required'), + closable: true, + content: ( + + {$t( + 'Co-host actions require the Intelligent Streaming Agent app. Install it to use this action.', + )} + + ), + cancelText: $t('Cancel'), + okText: $t('Install'), + okButtonProps: { type: 'primary' }, + onOk: () => { + void onInstallAgent(); + }, + cancelButtonProps: { style: { display: 'inline' } }, + }); + return; + } + + await alertAsync({ + type: 'confirm', + title: $t('Intelligent Streaming Agent Disabled'), + closable: true, + content: ( + + {$t( + 'The Intelligent Streaming Agent app is installed but currently disabled. Enable it to use this action.', + )} + + ), + cancelText: $t('Cancel'), + okText: $t('Enable'), + okButtonProps: { type: 'primary' }, + onOk: () => { + onEnableAgent(); + }, + cancelButtonProps: { style: { display: 'inline' } }, + }); + } + function setType(type: ActionType) { + if (requiresAgentApp(type) && !agentReady) { + void requireAgentApp(); + return; + } onChange(index, withActionDefaults({ type })); } @@ -104,6 +180,8 @@ function ActionEditor({ }); } + const actionOptions = getActionOptions(); + const props = (action.props || {}) as ExportedActionProps; const sceneName = props.scene?.name ?? ''; @@ -111,6 +189,44 @@ function ActionEditor({ const sourceName = props.source?.name ?? ''; const sourceMissing = !!sourceName && !sources.some(s => s.name === sourceName); + function renderAgentRequiredNotice() { + if (!isAgentInstalled) { + return ( +

+ {$t('Requires the Intelligent Streaming Agent app.')} + void onInstallAgent()}>{$t('Install')} +

+ ); + } + + return ( +

+ {$t('The Intelligent Streaming Agent app is disabled.')} + onEnableAgent()}>{$t('Enable')} +

+ ); + } + function renderControl() { switch (action.type) { case 'common.switch_to_scene': { @@ -200,6 +316,7 @@ function ActionEditor({ ); case 'co-host.instruction': + if (!agentReady) return renderAgentRequiredNotice(); return ( <> setType(val as ActionType)} placeholder={$t('Select an Action...')} - options={ACTION_OPTIONS} + options={actionOptions} />
@@ -283,6 +401,12 @@ interface Props { export default function AutomationEditor({ initial, onClose, onViewTemplates }: Props) { const { AutomationsService, ScenesService, SourcesService } = Services; + const { + isInstalled: isAgentInstalled, + isEnabled: isAgentEnabled, + installAgent, + enableAgent, + } = useAgentAppInstalled(); const { scenes, sources } = useVuex(() => ({ scenes: ScenesService.views.scenes.map(s => ({ id: s.id, name: s.name })), @@ -327,7 +451,11 @@ export default function AutomationEditor({ initial, onClose, onViewTemplates }: actions, enabled, }; - const issues = validateAutomation(draft, { scenes, sources }); + const issues = validateAutomation(draft, { + scenes, + sources, + agentAppReady: isAgentInstalled && isAgentEnabled, + }); const descriptionError = attempted ? issues.find(i => i.scope === 'description')?.message @@ -579,6 +707,10 @@ export default function AutomationEditor({ initial, onClose, onViewTemplates }: scenes={scenes} sources={sources} errors={actionErrors[i]} + isAgentInstalled={isAgentInstalled} + isAgentEnabled={isAgentEnabled} + onInstallAgent={installAgent} + onEnableAgent={enableAgent} onChange={handleActionChange} onRemove={handleActionRemove} /> diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index 9950b4243146..2c50760f9b9a 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { Button, Switch, Tooltip, Spin, Popconfirm, Select, Dropdown, Menu, Tag } from 'antd'; import { ModalLayout } from 'components-react/shared/ModalLayout'; import { useVuex } from 'components-react/hooks'; +import { useAgentAppInstalled } from 'components-react/hooks/useAgentAppInstalled'; import { Services } from 'components-react/service-provider'; import { $t } from 'services/i18n'; import { Conditions, GAME_NAMES } from 'services/stream-avatar/engine/conditions'; @@ -47,6 +48,7 @@ export default function EditAutomations() { scenes: ScenesService.views.scenes.map(s => ({ id: s.id, name: s.name })), sources: SourcesService.views.sources.map(s => ({ id: s.sourceId, name: s.name })), })); + const { isInstalled: isAgentInstalled, isEnabled: isAgentEnabled } = useAgentAppInstalled(); const [editingAutomation, setEditingAutomation] = useState(null); const [creating, setCreating] = useState(false); @@ -256,7 +258,11 @@ export default function EditAutomations() {
{filtered.map(automation => { - const issues = validateAutomation(automation, { scenes, sources }); + const issues = validateAutomation(automation, { + scenes, + sources, + agentAppReady: isAgentInstalled && isAgentEnabled, + }); return ( diff --git a/app/services/hosts.ts b/app/services/hosts.ts index f186c72494b9..9447e0566403 100644 --- a/app/services/hosts.ts +++ b/app/services/hosts.ts @@ -57,13 +57,13 @@ export class HostsService extends Service { } get streamAvatarApi() { - if (Util.shouldUseAvatarLocalHost()) { - return 'localhost:3000'; - } else if (Util.shouldUseBeta()) { - return 'ai-agent.streamlabs.com'; - } + // if (Util.shouldUseAvatarLocalHost()) { + // return 'localhost:3000'; + // } else if (Util.shouldUseBeta()) { + // return 'ai-agent.streamlabs.com'; + // } - return 'isa.streamlabs.com'; + return 'ai-agent.streamlabs.com'; } } From 5f0d46bac415dedf85a878d5215791663f5b6054 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Thu, 4 Jun 2026 12:01:33 -0700 Subject: [PATCH 38/79] feat(automations): add dropdownMatchSelectWidth prop to Select component in AutomationEditor --- .../windows/stream-avatar-automations/AutomationEditor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index bf611d379c1e..c773246c17ee 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -488,6 +488,7 @@ export default function AutomationEditor({ initial, onClose }: Props) { onChange={val => setSelectedGame(val)} style={{ flex: '0 0 auto' }} options={GAME_OPTIONS} + dropdownMatchSelectWidth={false} /> setType(val as ActionType)} - style={{ width: '100%' }} + placeholder={$t('Select an Action...')} options={ACTION_OPTIONS} /> @@ -285,23 +264,8 @@ function ActionEditor({ {renderControl()} -
- onInsert(index)} - /> - {isFirst ? ( - - ) : ( - onRemove(index)} - /> - )} +
onRemove(index)} title={$t('Remove reaction')}> +
); @@ -310,9 +274,10 @@ function ActionEditor({ interface Props { initial?: TAutomationExport; onClose: () => void; + onViewTemplates?: () => void; } -export default function AutomationEditor({ initial, onClose }: Props) { +export default function AutomationEditor({ initial, onClose, onViewTemplates }: Props) { const { AutomationsService, ScenesService, SourcesService } = Services; const { scenes, sources } = useVuex(() => ({ @@ -325,35 +290,34 @@ export default function AutomationEditor({ initial, onClose }: Props) { if (initial?.conditions?.[0]) { return (initial.conditions[0].type as string).split('.')[0]; } - return GAME_OPTIONS[0].value; + return ''; }); const [conditionType, setConditionType] = useState(() => { return (initial?.conditions?.[0]?.type as ConditionType) ?? ''; }); + const [conditionProps, setConditionProps] = useState>(() => { + return (initial?.conditions?.[0]?.props as Record) ?? {}; + }); const [rows, setRows] = useState( () => (initial?.actions as ExportedAction[])?.filter(a => a?.type).map(makeRow) ?? [ - makeRow({ type: 'common.save_replay' }), + { id: uuid(), action: { type: '' as ActionType } }, ], ); - // `rows` carries the stable drag keys; `actions` is the plain ordered list the - // validator and save payload consume. - const actions = rows.map(r => r.action); - // Enable/disable is managed from the list, not here; new automations default - // to enabled and edits preserve the existing value. + // Filter rows without a selected type — they're unfinished UI placeholders. + const actions = rows.filter(r => r.action.type).map(r => r.action); const enabled = initial?.enabled ?? true; const [saving, setSaving] = useState(false); const [error, setError] = useState(''); - // Required-field errors stay hidden on a fresh form until the first save - // attempt; existing automations show them immediately so a deleted scene/ - // source surfaces the moment the editor opens. const [attempted, setAttempted] = useState(!!initial); const conditionOptions = getConditionOptions(selectedGame); + const conditionPropsForSave = Object.keys(conditionProps).length > 0 ? conditionProps : undefined; + const draft: TAutomationExport = { description, - conditions: conditionType ? [{ type: conditionType }] : [], + conditions: conditionType ? [{ type: conditionType, props: conditionPropsForSave as any }] : [], actions, enabled, }; @@ -365,11 +329,9 @@ export default function AutomationEditor({ initial, onClose }: Props) { const conditionError = attempted ? issues.find(i => i.scope === 'conditions')?.message : undefined; - // "Add at least one action" has no actionIndex; show it once a save is tried. const actionsError = attempted ? issues.find(i => i.scope === 'action' && i.actionIndex === undefined)?.message : undefined; - // Per-action field errors render live so unavailable selections are obvious. const actionErrors: Record> = {}; issues.forEach(i => { if (i.scope === 'action' && i.actionIndex !== undefined && i.field) { @@ -378,14 +340,21 @@ export default function AutomationEditor({ initial, onClose }: Props) { } }); - useEffect(() => { - // Reset condition selection when game changes - if (conditionOptions.length > 0) { - const current = conditionOptions.find(o => o.value === conditionType); - if (!current) setConditionType(conditionOptions[0].value); - } else { - setConditionType(''); + function applyConditionType(type: ConditionType | '') { + setConditionType(type); + const def = type ? Conditions[type] : undefined; + const defaults: Record = {}; + if (def?.properties) { + for (const [key, prop] of Object.entries(def.properties)) { + if ('default' in prop.config) defaults[key] = prop.config.default; + } } + setConditionProps(defaults); + } + + useEffect(() => { + const current = conditionOptions.find(o => o.value === conditionType); + if (!current) applyConditionType(''); }, [selectedGame]); function handleActionChange(index: number, action: ExportedAction) { @@ -397,17 +366,7 @@ export default function AutomationEditor({ initial, onClose }: Props) { } function handleAddAction() { - setRows(prev => [...prev, makeRow({ type: 'common.save_replay' })]); - } - - // Insert a new action directly after `afterIndex` so steps can be added - // between existing ones, not just appended. - function handleInsertAction(afterIndex: number) { - setRows(prev => { - const next = [...prev]; - next.splice(afterIndex + 1, 0, makeRow({ type: 'common.save_replay' })); - return next; - }); + setRows(prev => [...prev, { id: uuid(), action: { type: '' as ActionType } }]); } async function handleSave() { @@ -422,7 +381,9 @@ export default function AutomationEditor({ initial, onClose }: Props) { try { const payload: Omit = { description: description.trim(), - conditions: conditionType ? [{ type: conditionType }] : [], + conditions: conditionType + ? [{ type: conditionType, props: conditionPropsForSave as any }] + : [], actions, enabled, }; @@ -440,103 +401,190 @@ export default function AutomationEditor({ initial, onClose }: Props) { } } - const getButtonText = () => { - if (saving) return $t('Saving...'); - if (initial) return $t('Save Changes'); - return $t('Create Automation'); + const sectionLabelStyle: CSSProperties = { + display: 'block', + marginBottom: 8, + fontWeight: 700, + fontSize: 15, + color: 'var(--title)', + }; + + const sectionSubtitleStyle: CSSProperties = { + margin: 0, + fontSize: 12, + color: 'var(--paragraph)', }; + // ponytail: var avoids no-nested-ternary lint rule + let saveLabel = initial ? $t('Save Automation') : $t('Create Automation'); + if (saving) saveLabel = $t('Saving...'); + const footer = ( <> - - + + {saveLabel} + ); return ( -

- {initial ? $t('Edit Automation') : $t('New Automation')} -

+ {/* Header */} +
+

+ {initial ? $t('Edit Automation') : $t('Add New Automation')} +

+ {onViewTemplates && ( + + )} +
-
+
{/* Description */} - setDescription(val)} - placeholder={$t('e.g. Victory Royale reaction')} - validateStatus={descriptionError ? 'error' : undefined} - help={descriptionError} - /> +
+ + setDescription(val)} + placeholder={$t('e.g. Victory Royale reaction')} + /> + {descriptionError &&

{descriptionError}

} +
- {/* Condition */} -
- -
+ {/* Trigger */} +
+ +

+ {$t('Set the condition that activates this automation')} +

+
setConditionType(val as ConditionType)} - style={{ flex: 1, minWidth: 0 }} - placeholder={ - conditionOptions.length === 0 ? $t('No conditions available') : undefined - } + onChange={val => applyConditionType(val as ConditionType)} + placeholder={$t('Select a Condition')} options={conditionOptions} />
{conditionError &&

{conditionError}

} + + {/* Condition-specific property inputs (e.g. elimination_count range) */} + {conditionType && + (() => { + const def = Conditions[conditionType as ConditionType]; + if (!def?.properties) return null; + return Object.entries(def.properties).map(([key, prop]) => { + if (prop instanceof Properties.SliderRange) { + const { min, max, step, label } = prop.config; + const value = (conditionProps[key] as [number, number]) ?? prop.config.default; + return ( +
+
+ {label} + + {value[0]} – {value[1]} + +
+ setConditionProps(prev => ({ ...prev, [key]: val }))} + /> +
+ ); + } + return null; + }); + })()}
- {/* Actions */} + {/* Reactions */}
- + +

+ {$t('Add action(s) to perform after the trigger')} +

list={rows} setList={setRows} handle=".sa-action-drag-handle" animation={200} tag="div" + style={{ display: 'flex', flexDirection: 'column', gap: 24 }} > {rows.map((row, i) => (
))} {actionsError &&

{actionsError}

} - +
{error &&

{error}

} diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less b/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less index 60845acc8467..c0487d699b85 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less @@ -1,15 +1,16 @@ +// ── Table ───────────────────────────────────────────── + .table { width: 100%; - border-collapse: collapse; - font-size: 13px; + border-collapse: separate; + border-spacing: 0; + font-size: 14px; } .table th { text-align: left; - text-transform: uppercase; - font-size: 11px; + font-size: 14px; font-weight: 400; - letter-spacing: 0.4px; color: var(--paragraph); padding: 8px 12px; border-bottom: 1px solid var(--border); @@ -23,18 +24,37 @@ vertical-align: middle; } -.table tbody tr:hover { +.table tbody tr:hover td { background: var(--section); } -.descCell { - font-weight: 600; +.table th:first-child { + padding-left: 0; +} + +.table tbody td:first-child { + border-radius: 8px 0 0 8px; +} + +.table tbody td:last-child { + border-radius: 0 8px 8px 0; } .mutedCell { color: var(--paragraph); } +.badge { + background: #4f5e65 !important; + border-color: transparent !important; + border-radius: 6px !important; + color: #fff !important; + font-size: 11px !important; + font-weight: 700 !important; + padding: 3px 6px !important; + margin-right: 4px; +} + .actionsCol { width: 1%; } @@ -48,7 +68,7 @@ .rowActions i { cursor: pointer; - color: var(--icon-active); + color: #91979a; font-size: 14px; } @@ -71,24 +91,103 @@ color: var(--icon-active); } -.badge { - display: inline-block; - background: var(--section); - border: 1px solid var(--border); - border-radius: 4px; - padding: 2px 8px; - font-size: 11px; - margin-right: 6px; - white-space: nowrap; +.message { + padding: 24px; + color: var(--paragraph); + text-align: center; +} + +// ── Page header ─────────────────────────────────────── + +.pageHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 20px; +} + +.titleBlock { + flex: 1; + min-width: 0; +} + +.pageTitle { + margin: 0 0 4px; + font-size: 20px; + font-weight: 700; color: var(--title); + display: flex; + align-items: center; + gap: 8px; } -.message { - padding: 24px; +.infoIcon { + font-size: 14px; color: var(--paragraph); + cursor: default; +} + +.pageSubtitle { + margin: 0; + font-size: 13px; + color: var(--paragraph); +} + +.controls { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.filterLabel { + font-size: 13px; + color: var(--paragraph); + white-space: nowrap; +} + +.addNewBtn { + display: flex; + align-items: center; + white-space: nowrap; +} + +// ── Empty state ─────────────────────────────────────── + +.emptyCard { + border: 1px dashed var(--border); + border-radius: 8px; + padding: 40px 24px; text-align: center; + margin-top: 8px; +} + +.emptyImage { + width: 260px; + height: 160px; + background: var(--section); + border-radius: 8px; + margin: 0 auto 24px; } -.empty { - margin-top: 48px; +.emptyTitle { + margin: 0 0 10px; + font-size: 16px; + font-weight: 700; + color: var(--title); +} + +.emptyDesc { + margin: 0 auto 24px; + max-width: 480px; + font-size: 13px; + color: var(--paragraph); + line-height: 1.5; +} + +.emptyActions { + display: flex; + justify-content: center; + gap: 12px; } diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index 7e477ff2e827..4057957c5250 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Switch, Tooltip, Empty, Spin, Popconfirm } from 'antd'; +import { Button, Switch, Tooltip, Spin, Popconfirm, Select, Dropdown, Menu, Tag } from 'antd'; import { ModalLayout } from 'components-react/shared/ModalLayout'; import { useVuex } from 'components-react/hooks'; import { Services } from 'components-react/service-provider'; @@ -9,6 +9,7 @@ import { ActionRegistry } from 'services/stream-avatar/engine/actions'; import { validateAutomation } from 'services/stream-avatar/engine/validation'; import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; import AutomationEditor from './AutomationEditor'; +import PreMadeAutomations from './PreMadeAutomations'; import styles from './EditAutomations.m.less'; function conditionLabel(condition: { type: string } | null) { @@ -34,6 +35,10 @@ function summarizeActions(actions: TAutomationExport['actions']) { .join(', '); } +const GAME_FILTER_OPTIONS = Object.entries(GAME_NAMES) + .map(([id, name]) => ({ label: name, value: id })) + .sort((a, b) => a.label.localeCompare(b.label)); + export default function EditAutomations() { const { AutomationsService, AutomationsEngineService, ScenesService, SourcesService } = Services; const { automations, loading, scenes, sources } = useVuex(() => ({ @@ -45,13 +50,14 @@ export default function EditAutomations() { const [editingAutomation, setEditingAutomation] = useState(null); const [creating, setCreating] = useState(false); + const [showPreMade, setShowPreMade] = useState(false); + const [filterGame, setFilterGame] = useState(''); const [simulatingId, setSimulatingId] = useState(null); useEffect(() => { AutomationsService.actions.fetchAll(); }, []); - // Read queryParams reactively so changes re-trigger when window is reused. const { WindowsService } = Services; const { editAutomationId, createNew } = useVuex(() => ({ editAutomationId: WindowsService.state.child.queryParams?.editAutomationId as @@ -60,17 +66,14 @@ export default function EditAutomations() { createNew: !!WindowsService.state.child.queryParams?.createNew, })); - // True when launched from the AutomationsElement — close the window on done instead of returning to list. const launchedFromElement = !!editAutomationId || createNew; - // Jump straight into create flow when launched with createNew param. useEffect(() => { if (!createNew) return; setCreating(true); setEditingAutomation(null); }, [createNew]); - // Jump straight into the editor for a specific automation when launched with editAutomationId. useEffect(() => { if (!editAutomationId || automations.length === 0) return; const target = automations.find(a => a.id === editAutomationId); @@ -81,8 +84,6 @@ export default function EditAutomations() { if (!automation.id || simulatingId !== null) return; setSimulatingId(automation.id); try { - // `.return` so the promise resolves only after the worker finishes the - // simulation (including its revert delay), keeping the spinner accurate. await AutomationsEngineService.actions.return.simulateAutomation(automation.id); } finally { setSimulatingId(null); @@ -105,11 +106,13 @@ export default function EditAutomations() { function edit(automation: TAutomationExport) { setEditingAutomation(automation); setCreating(false); + setShowPreMade(false); } function create() { setEditingAutomation(null); setCreating(true); + setShowPreMade(false); } function closeEditor() { @@ -118,34 +121,132 @@ export default function EditAutomations() { } else { setEditingAutomation(null); setCreating(false); + setShowPreMade(false); } } + function closeWindow() { + WindowsService.actions.closeChildWindow(); + } + if (creating || editingAutomation) { - return ; + return ( + { + closeEditor(); + setShowPreMade(true); + }} + /> + ); } + if (showPreMade) { + return setShowPreMade(false)} />; + } + + const filtered = filterGame + ? automations.filter(a => + a.conditions.some( + c => (Conditions[c.type as keyof typeof Conditions]?.group ?? '') === filterGame, + ), + ) + : automations; + + const addNewMenu = ( + + + {$t('Add New Automation')} + + setShowPreMade(true)}> + {$t('Select from Pre-made')} + + + ); + + const footer = ( + <> + + + + ); + return ( - + +
+
+

+ {$t('Automations')} + + + +

+

+ {$t('Automatically trigger on stream effects in response to gameplay events.')} +

+
+ +
+ {$t('Filter by')} +
{$t('Description')}{$t('When')}{$t('Do')}{$t('Game')}{$t('DESCRIPTION')}{$t('TRIGGER')}{$t('REACTION')}{$t('GAME')}
diff --git a/app/services/hosts.ts b/app/services/hosts.ts index 601b70d8ccc1..a38ad5694163 100644 --- a/app/services/hosts.ts +++ b/app/services/hosts.ts @@ -55,8 +55,8 @@ export class HostsService extends Service { // return 'ai-agent.streamlabs.com'; // } - //return 'ai-agent.streamlabs.com'; - return 'localhost:3000'; + return 'ai-agent.streamlabs.com'; + //return 'localhost:3000'; } } diff --git a/app/services/stream-avatar/engine/validation.ts b/app/services/stream-avatar/engine/validation.ts index 02cd19418cad..97d089217c05 100644 --- a/app/services/stream-avatar/engine/validation.ts +++ b/app/services/stream-avatar/engine/validation.ts @@ -1,5 +1,6 @@ import { $t } from 'services/i18n'; import { Conditions } from './conditions'; +import { ActionRegistry } from './actions'; import type { TAutomationExport } from './automations'; import type { ExportedAction, ExportedActionProps } from './actions'; @@ -13,6 +14,8 @@ export interface IResourceRef { export interface IAvailableResources { scenes: IResourceRef[]; sources: IResourceRef[]; + /** Whether the Intelligent Streaming Agent app is installed AND enabled, gating co-host actions. */ + agentAppReady?: boolean; } /** Where an issue applies, so the editor can render it next to the right field. */ @@ -58,6 +61,19 @@ function validateAction( message, }); + if ( + action?.type && + ActionRegistry[action.type]?.group === 'co-host' && + resources.agentAppReady === false + ) { + issues.push( + actionIssue( + 'type', + $t('Requires the Intelligent Streaming Agent app to be installed and enabled.'), + ), + ); + } + switch (action?.type) { case 'common.switch_to_scene': { const name = props.scene?.name?.trim(); From d1c22106e4f1c6648b0629e09d5f6e42d068b492 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 8 Jul 2026 10:29:52 -0700 Subject: [PATCH 20/79] refactor(automations): streamline agent app detection logic and rename notification function --- .../hooks/useAgentAppInstalled.ts | 44 +++++++------------ .../AutomationEditor.tsx | 9 ++-- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/app/components-react/hooks/useAgentAppInstalled.ts b/app/components-react/hooks/useAgentAppInstalled.ts index df6afe9a4fd3..9d4419ab8766 100644 --- a/app/components-react/hooks/useAgentAppInstalled.ts +++ b/app/components-react/hooks/useAgentAppInstalled.ts @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; +import { useVuex } from 'components-react/hooks'; import { Services } from 'components-react/service-provider'; import { EMenuItemKey } from 'services/side-nav'; import { getOS, OS } from 'util/operating-systems'; @@ -12,40 +13,26 @@ export const AGENT_APP_ID = '93125d1c33'; * the same detection/redirect logic AILanding.tsx uses for its co-host * feature card. Installed and enabled are tracked separately since a user * can install the app but later disable it from Settings > Installed Apps. + * + * Reads reactively off PlatformAppsService's Vuex state (rather than local + * component state) so every consumer of this hook — e.g. the automations + * list and its edit modal — stays in sync the instant the app is installed + * or enabled anywhere, without needing to remount. */ export function useAgentAppInstalled() { const { NavigationService, PlatformAppsService, SideNavService } = Services; - const [isInstalled, setIsInstalled] = useState(false); - const [isEnabled, setIsEnabled] = useState(false); useEffect(() => { - let active = true; - let processing = false; - - void loadProductionApps(); - - return () => { - active = false; - }; - - async function loadProductionApps() { - if (getOS() !== OS.Windows) return; - if (processing) return; - - processing = true; - setIsInstalled(false); - setIsEnabled(false); - await PlatformAppsService.actions.return.loadProductionApps(); - - if (!active) return; - - const app = PlatformAppsService.views.productionApps.find(a => a.id === AGENT_APP_ID); - setIsInstalled(!!app); - setIsEnabled(!!app?.enabled); - processing = false; - } + if (getOS() !== OS.Windows) return; + void PlatformAppsService.actions.return.loadProductionApps(); }, []); + const { isInstalled, isEnabled } = useVuex(() => { + if (getOS() !== OS.Windows) return { isInstalled: false, isEnabled: false }; + const app = PlatformAppsService.views.productionApps.find(a => a.id === AGENT_APP_ID); + return { isInstalled: !!app, isEnabled: !!app?.enabled }; + }); + async function installAgent() { await PlatformAppsService.actions.return.refreshProductionApps(); NavigationService.actions.navigate('PlatformAppStore', { appId: AGENT_APP_STORE_ID }); @@ -54,7 +41,6 @@ export function useAgentAppInstalled() { function enableAgent() { PlatformAppsService.actions.setEnabled(AGENT_APP_ID, true); - setIsEnabled(true); } return { isInstalled, isEnabled, installAgent, enableAgent }; diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index 9fbbc77dd1c1..1759d6662971 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -120,7 +120,7 @@ function ActionEditor({ }: ActionEditorProps) { const agentReady = isAgentInstalled && isAgentEnabled; - async function requireAgentApp() { + async function notifyAgentRequired() { if (!isAgentInstalled) { await alertAsync({ type: 'confirm', @@ -166,11 +166,12 @@ function ActionEditor({ } function setType(type: ActionType) { + onChange(index, withActionDefaults({ type })); + // Always apply the selection — this is informational, not a gate, so it + // can't leave the Select showing a value that was never actually set. if (requiresAgentApp(type) && !agentReady) { - void requireAgentApp(); - return; + void notifyAgentRequired(); } - onChange(index, withActionDefaults({ type })); } function setProp(key: string, value: unknown) { From 0000e9ebad1f97e13849b59e774be1bb843e0c59 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 8 Jul 2026 10:31:00 -0700 Subject: [PATCH 21/79] feat(automations): add ISA app requirements and status messages to automation prompts --- app/i18n/en-US/stream-avatar-automations.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/i18n/en-US/stream-avatar-automations.json b/app/i18n/en-US/stream-avatar-automations.json index 78b209348dad..c5ead1874b84 100644 --- a/app/i18n/en-US/stream-avatar-automations.json +++ b/app/i18n/en-US/stream-avatar-automations.json @@ -54,6 +54,16 @@ "Hide if condition is false": "Hide if condition is false", "Show if condition is false": "Show if condition is false", "The co-host will automatically comment based on the active game condition.": "The co-host will automatically comment based on the active game condition.", + "Requires ISA App": "Requires ISA App", + "Intelligent Streaming Agent Required": "Intelligent Streaming Agent Required", + "Co-host actions require the Intelligent Streaming Agent app. Install it to use this action.": "Co-host actions require the Intelligent Streaming Agent app. Install it to use this action.", + "Intelligent Streaming Agent Disabled": "Intelligent Streaming Agent Disabled", + "The Intelligent Streaming Agent app is installed but currently disabled. Enable it to use this action.": "The Intelligent Streaming Agent app is installed but currently disabled. Enable it to use this action.", + "Requires the Intelligent Streaming Agent app.": "Requires the Intelligent Streaming Agent app.", + "The Intelligent Streaming Agent app is disabled.": "The Intelligent Streaming Agent app is disabled.", + "Install": "Install", + "Enable": "Enable", + "Cancel": "Cancel", "Please fix the highlighted fields before saving.": "Please fix the highlighted fields before saving.", "Failed to save automation.": "Failed to save automation.", "Add a description.": "Add a description.", @@ -66,5 +76,6 @@ "Select a source.": "Select a source.", "Source \"%{name}\" is unavailable.": "Source \"%{name}\" is unavailable.", "Enter an instruction for the co-host.": "Enter an instruction for the co-host.", - "Instruction must be %{max} characters or fewer.": "Instruction must be %{max} characters or fewer." + "Instruction must be %{max} characters or fewer.": "Instruction must be %{max} characters or fewer.", + "Requires the Intelligent Streaming Agent app to be installed and enabled.": "Requires the Intelligent Streaming Agent app to be installed and enabled." } From fa54ec3f414ac24d76f3cd0e52eac9f4275110d8 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 8 Jul 2026 11:20:03 -0700 Subject: [PATCH 22/79] feat(automations): enhance error handling and retry logic in automation services and components --- .../editor/elements/AutomationsElement.tsx | 37 ++++++- .../editor/elements/SceneSelector.tsx | 42 ++++--- .../EditAutomations.tsx | 38 +++++-- app/i18n/en-US/stream-avatar-automations.json | 3 + .../stream-avatar/agent-socket-service.ts | 104 +++++++++++++++++- .../stream-avatar/automations-service.ts | 47 ++++++++ 6 files changed, 238 insertions(+), 33 deletions(-) diff --git a/app/components-react/editor/elements/AutomationsElement.tsx b/app/components-react/editor/elements/AutomationsElement.tsx index 6e9394436517..467f8c1730c3 100644 --- a/app/components-react/editor/elements/AutomationsElement.tsx +++ b/app/components-react/editor/elements/AutomationsElement.tsx @@ -19,17 +19,20 @@ function conditionLabel(automation: TAutomationExport) { } function AutomationsContent() { - const { AutomationsService, AutomationsEngineService } = Services; - const { automations, loading } = useVuex(() => ({ + const { AutomationsService, AutomationsEngineService, AgentSocketService, UserService } = Services; + const { automations, loaded, error, isLoggedIn } = useVuex(() => ({ automations: AutomationsService.state.automations, - loading: AutomationsService.state.loading, + loaded: AutomationsService.state.loaded, + error: AutomationsService.state.error, + isLoggedIn: UserService.views.isLoggedIn, })); const [simulatingId, setSimulatingId] = useState(null); useEffect(() => { + if (!isLoggedIn) return; AutomationsService.actions.fetchAll(); - }, []); + }, [isLoggedIn]); async function simulate(e: React.MouseEvent, id: number) { e.stopPropagation(); @@ -54,7 +57,31 @@ function AutomationsContent() { AutomationsService.actions.showCreateAutomation(); } - if (loading) { + function retryNow() { + AgentSocketService.actions.reconnect(); + AutomationsService.actions.fetchAll(); + } + + if (!isLoggedIn) { + return ( +
+ {$t('Log in to use Automations.')} +
+ ); + } + + if (error) { + return ( +
+ {$t('Unable to reach the automations server. Retrying…')} + + + +
+ ); + } + + if (!loaded) { return (
diff --git a/app/components-react/editor/elements/SceneSelector.tsx b/app/components-react/editor/elements/SceneSelector.tsx index ab36ebd683dd..4449fb903e7a 100644 --- a/app/components-react/editor/elements/SceneSelector.tsx +++ b/app/components-react/editor/elements/SceneSelector.tsx @@ -26,23 +26,27 @@ function SceneSelector() { ProjectorService, EditorCommandsService, AutomationsService, + UserService, } = Services; const { treeSort } = useTree(true); const [showDropdown, setShowDropdown] = useState(false); - const { scenes, activeSceneId, activeScene, collections, activeCollection } = useVuex(() => ({ - scenes: ScenesService.views.scenes.map(scene => ({ - title: , - key: scene.id, - selectable: true, - isLeaf: true, - })), - activeScene: ScenesService.views.activeScene, - activeSceneId: ScenesService.views.activeSceneId, - activeCollection: SceneCollectionsService.activeCollection, - collections: SceneCollectionsService.collections, - })); + const { scenes, activeSceneId, activeScene, collections, activeCollection, isLoggedIn } = useVuex( + () => ({ + scenes: ScenesService.views.scenes.map(scene => ({ + title: , + key: scene.id, + selectable: true, + isLeaf: true, + })), + activeScene: ScenesService.views.activeScene, + activeSceneId: ScenesService.views.activeSceneId, + activeCollection: SceneCollectionsService.activeCollection, + collections: SceneCollectionsService.collections, + isLoggedIn: UserService.views.isLoggedIn, + }), + ); function showContextMenu(info: { event: React.MouseEvent }) { info.event.preventDefault(); @@ -178,12 +182,14 @@ function SceneSelector() { - - AutomationsService.actions.showAutomations()} - /> - + {isLoggedIn && ( + + AutomationsService.actions.showAutomations()} + /> + + )}
a.label.localeCompare(b.label)); export default function EditAutomations() { - const { AutomationsService, AutomationsEngineService, ScenesService, SourcesService } = Services; - const { automations, loading, scenes, sources } = useVuex(() => ({ + const { + AutomationsService, + AutomationsEngineService, + AgentSocketService, + ScenesService, + SourcesService, + } = Services; + const { automations, loaded, error, scenes, sources } = useVuex(() => ({ automations: AutomationsService.state.automations, - loading: AutomationsService.state.loading, + loaded: AutomationsService.state.loaded, + error: AutomationsService.state.error, scenes: ScenesService.views.scenes.map(s => ({ id: s.id, name: s.name })), sources: SourcesService.views.sources.map(s => ({ id: s.sourceId, name: s.name })), })); @@ -60,6 +68,13 @@ export default function EditAutomations() { AutomationsService.actions.fetchAll(); }, []); + function retryNow() { + // fetchAll() alone can't help if the socket itself never connected — force + // a fresh connection attempt too, not just another doomed-to-timeout call. + AgentSocketService.actions.reconnect(); + AutomationsService.actions.fetchAll(); + } + const { WindowsService } = Services; const { editAutomationId, createNew } = useVuex(() => ({ editAutomationId: WindowsService.state.child.queryParams?.editAutomationId as @@ -210,9 +225,18 @@ export default function EditAutomations() { - {loading &&
{$t('Loading...')}
} + {!loaded && !error && } + + {error && ( +
+ {$t('Unable to reach the automations server. Retrying…')} + +
+ )} - {!loading && automations.length === 0 && ( + {loaded && automations.length === 0 && (
)} - {!loading && automations.length > 0 && filtered.length === 0 && ( + {loaded && automations.length > 0 && filtered.length === 0 && (
{$t('No automations match the selected filter.')}
)} - {!loading && filtered.length > 0 && ( + {loaded && filtered.length > 0 && ( diff --git a/app/i18n/en-US/stream-avatar-automations.json b/app/i18n/en-US/stream-avatar-automations.json index c5ead1874b84..c8c5f2d95abb 100644 --- a/app/i18n/en-US/stream-avatar-automations.json +++ b/app/i18n/en-US/stream-avatar-automations.json @@ -35,6 +35,9 @@ "Saving...": "Saving...", "Delete this automation?": "Delete this automation?", "Loading...": "Loading...", + "Unable to reach the automations server. Retrying…": "Unable to reach the automations server. Retrying…", + "Retry Now": "Retry Now", + "Log in to use Automations.": "Log in to use Automations.", "Test automation": "Test automation", "Enabled": "Enabled", "Disabled": "Disabled", diff --git a/app/services/stream-avatar/agent-socket-service.ts b/app/services/stream-avatar/agent-socket-service.ts index bd8dbd9a2616..407b4302365d 100644 --- a/app/services/stream-avatar/agent-socket-service.ts +++ b/app/services/stream-avatar/agent-socket-service.ts @@ -1,4 +1,5 @@ -import { InitAfter, Inject, Service } from 'services/core'; +import { InitAfter, Inject } from 'services/core'; +import { StatefulService, mutation } from 'services/core/stateful-service'; import { StreamAvatarApiService } from './stream-avatar-api-service'; import { HostsService } from 'services/hosts'; import { UserService } from 'services/user'; @@ -11,8 +12,34 @@ interface SocketAck { error?: string; } +export type TAgentSocketStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; + +interface IAgentSocketState { + status: TAgentSocketStatus; +} + +/** How many consecutive connect_error events (without an authenticated in between) + * before we surface a real error state instead of quietly relying on socket.io's + * own indefinite reconnection. */ +const ERROR_AFTER_CONSECUTIVE_FAILURES = 3; + +/** Bounds how long `call()` will wait for the socket to authenticate, so a call + * fails fast (and can be retried/surfaced) instead of hanging forever if the + * server never comes back. */ +const READY_TIMEOUT_MS = 15000; + +/** Backoff for retrying `connect()` itself when it fails before ever creating a + * socket (e.g. the token-mint fetch fails outright) — socket.io's own + * `reconnection` option can't help here since no socket instance exists yet. */ +const CONNECT_RETRY_BASE_DELAY_MS = 2000; +const CONNECT_RETRY_MAX_DELAY_MS = 30000; + @InitAfter('UserService') -export class AgentSocketService extends Service { +export class AgentSocketService extends StatefulService { + static initialState: IAgentSocketState = { + status: 'disconnected', + }; + @Inject() private streamAvatarApiService: StreamAvatarApiService; @Inject() private hostsService: HostsService; @Inject() private userService: UserService; @@ -20,6 +47,9 @@ export class AgentSocketService extends Service { private socket: SocketIOClient.Socket | null = null; private readyPromise: Promise = Promise.resolve(); private readyResolve: (() => void) | null = null; + private consecutiveErrors = 0; + private connectRetryTimer: number | null = null; + private connectRetryDelay = CONNECT_RETRY_BASE_DELAY_MS; init() { console.log('[AgentSocket] init() called. isWorkerWindow:', Utils.isWorkerWindow()); @@ -34,6 +64,45 @@ export class AgentSocketService extends Service { this.resetReady(); this.connect(); }); + this.userService.userLogout.subscribe(() => { + console.log('[AgentSocket] userLogout fired, disconnecting'); + this.clearConnectRetry(); + this.socket?.disconnect(); + this.socket = null; + this.resetReady(); + this.SET_STATUS('disconnected'); + }); + } + + /** Force a fresh connection attempt now, e.g. from a manual "Retry Now" UI action. */ + reconnect() { + this.clearConnectRetry(); + this.consecutiveErrors = 0; + this.resetReady(); + this.connect(); + } + + private clearConnectRetry() { + if (this.connectRetryTimer !== null) { + clearTimeout(this.connectRetryTimer); + this.connectRetryTimer = null; + } + this.connectRetryDelay = CONNECT_RETRY_BASE_DELAY_MS; + } + + private scheduleConnectRetry() { + if (this.connectRetryTimer !== null) return; + console.log(`[AgentSocket] scheduling reconnect attempt in ${this.connectRetryDelay}ms`); + this.connectRetryTimer = window.setTimeout(() => { + this.connectRetryTimer = null; + if (this.userService.isLoggedIn) this.connect(); + }, this.connectRetryDelay); + this.connectRetryDelay = Math.min(this.connectRetryDelay * 2, CONNECT_RETRY_MAX_DELAY_MS); + } + + @mutation() + private SET_STATUS(status: TAgentSocketStatus) { + this.state.status = status; } private resetReady() { @@ -48,6 +117,7 @@ export class AgentSocketService extends Service { } private async connect() { + this.SET_STATUS('connecting'); try { console.log('[AgentSocket] connect() start. URL:', this.socketUrl); const io = (await importSocketIOClient()).default; @@ -78,6 +148,9 @@ export class AgentSocketService extends Service { this.socket.on('authenticated', (payload: any) => { console.log('[AgentSocket] "authenticated" event received', payload); + this.consecutiveErrors = 0; + this.clearConnectRetry(); + this.SET_STATUS('connected'); this.readyResolve?.(); }); @@ -88,10 +161,16 @@ export class AgentSocketService extends Service { this.socket.on('disconnect', (reason: any) => { console.warn('[AgentSocket] "disconnect" event:', reason); this.resetReady(); + if (this.state.status !== 'error') this.SET_STATUS('disconnected'); }); this.socket.on('connect_error', async (err: any) => { console.error('[AgentSocket] "connect_error" event:', err?.message, err); + this.consecutiveErrors += 1; + if (this.consecutiveErrors >= ERROR_AFTER_CONSECUTIVE_FAILURES) { + this.SET_STATUS('error'); + } + if (err?.message?.includes('unauthorized') || err?.message?.includes('auth')) { try { const freshJwt = await this.streamAvatarApiService.getToken(true); @@ -110,6 +189,13 @@ export class AgentSocketService extends Service { this.socket.connect(); } catch (e: unknown) { console.error('[AgentSocket] connect() threw:', e); + this.consecutiveErrors += 1; + if (this.consecutiveErrors >= ERROR_AFTER_CONSECUTIVE_FAILURES) { + this.SET_STATUS('error'); + } + // We never got as far as creating a socket, so socket.io's own + // `reconnection` option has nothing to retry — schedule our own attempt. + this.scheduleConnectRetry(); } } @@ -118,7 +204,7 @@ export class AgentSocketService extends Service { `[AgentSocket] call("${event}") awaiting ready... socket connected:`, this.socket?.connected, ); - await this.readyPromise; + await this.waitForReady(); console.log(`[AgentSocket] call("${event}") ready resolved, emitting`); return new Promise((resolve, reject) => { this.socket!.emit(event, ...args, (res: SocketAck) => { @@ -129,6 +215,18 @@ export class AgentSocketService extends Service { }); } + private waitForReady(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Automations server connection timed out')); + }, READY_TIMEOUT_MS); + this.readyPromise.then(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + getAutomations(): Promise { return this.call('getAutomations'); } diff --git a/app/services/stream-avatar/automations-service.ts b/app/services/stream-avatar/automations-service.ts index 3420db728449..7556c83510a3 100644 --- a/app/services/stream-avatar/automations-service.ts +++ b/app/services/stream-avatar/automations-service.ts @@ -12,20 +12,28 @@ interface IAutomationsState { automations: TAutomationExport[]; loading: boolean; loaded: boolean; + error: boolean; } +const RETRY_BASE_DELAY_MS = 2000; +const RETRY_MAX_DELAY_MS = 30000; + @InitAfter('UserService') export class AutomationsService extends StatefulService { static initialState: IAutomationsState = { automations: [], loading: false, loaded: false, + error: false, }; @Inject() private agentSocketService: AgentSocketService; @Inject() private userService: UserService; @Inject() private windowsService: WindowsService; + private retryDelay = RETRY_BASE_DELAY_MS; + private retryTimer: number | null = null; + init() { console.log('[AutomationsService] init() called. isWorkerWindow:', Utils.isWorkerWindow()); if (!Utils.isWorkerWindow()) return; @@ -37,6 +45,15 @@ export class AutomationsService extends StatefulService { console.log('[AutomationsService] userLogin fired, fetching'); this.fetchAll(); }); + this.userService.userLogout.subscribe(() => { + console.log('[AutomationsService] userLogout fired, resetting'); + if (this.retryTimer !== null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.retryDelay = RETRY_BASE_DELAY_MS; + this.RESET(); + }); } showAutomations() { @@ -79,6 +96,7 @@ export class AutomationsService extends StatefulService { this.state.automations = automations; this.state.loaded = true; this.state.loading = false; + this.state.error = false; } @mutation() @@ -86,6 +104,11 @@ export class AutomationsService extends StatefulService { this.state.loading = loading; } + @mutation() + private SET_ERROR(error: boolean) { + this.state.error = error; + } + @mutation() private ADD_AUTOMATION(automation: TAutomationExport) { this.state.automations = [automation, ...this.state.automations]; @@ -103,19 +126,43 @@ export class AutomationsService extends StatefulService { this.state.automations = this.state.automations.filter(a => a.id !== id); } + @mutation() + private RESET() { + this.state.automations = []; + this.state.loading = false; + this.state.loaded = false; + this.state.error = false; + } + async fetchAll(): Promise { console.log('[AutomationsService] fetchAll() start'); + if (this.retryTimer !== null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } this.SET_LOADING(true); try { const automations = await this.agentSocketService.getAutomations(); console.log('[AutomationsService] fetchAll() got', automations?.length, 'automations'); this.SET_AUTOMATIONS(automations as TAutomationExport[]); + this.retryDelay = RETRY_BASE_DELAY_MS; } catch (e: unknown) { this.SET_LOADING(false); + this.SET_ERROR(true); console.error('[AutomationsService] fetchAll failed', e); + this.scheduleRetry(); } } + private scheduleRetry() { + if (this.retryTimer !== null) return; + this.retryTimer = window.setTimeout(() => { + this.retryTimer = null; + if (this.userService.isLoggedIn) this.fetchAll(); + }, this.retryDelay); + this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY_MS); + } + async create(automation: Omit): Promise { const created = await this.agentSocketService.createAutomation(automation); this.ADD_AUTOMATION(created as TAutomationExport); From 0e29a3d4ca920eb40e41e3dcd06d2f8276657486 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 8 Jul 2026 14:20:59 -0700 Subject: [PATCH 23/79] feat(automations): implement analytics tracking for automation actions and events --- .../AutomationEditor.tsx | 7 +++++++ .../AutomationsAnalytics.ts | 21 +++++++++++++++++++ .../EditAutomations.tsx | 2 ++ .../PreMadeAutomations.tsx | 6 ++++++ .../automations-engine-service.ts | 9 ++++++++ app/services/usage-statistics.ts | 3 ++- 6 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 app/components-react/windows/stream-avatar-automations/AutomationsAnalytics.ts diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index 1759d6662971..649f0a903611 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -16,6 +16,7 @@ import { MAX_INSTRUCTION_LENGTH, } from 'services/stream-avatar/engine/validation'; import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; +import { AutomationsAnalytics } from './AutomationsAnalytics'; import type { ActionType, ExportedAction, @@ -523,10 +524,16 @@ export default function AutomationEditor({ initial, onClose, onViewTemplates }: enabled, }; + const game = payload.conditions[0]?.type.split('.')[0] ?? 'unknown'; + const trigger = payload.conditions[0]?.type ?? 'unknown'; + const actionTypes = payload.actions.map((a: { type: string }) => a.type); + if (initial?.id) { await AutomationsService.actions.update(initial.id, payload); + AutomationsAnalytics.automationUpdated(game, trigger, actionTypes); } else { await AutomationsService.actions.create(payload); + AutomationsAnalytics.automationCreated(game, trigger, actionTypes); } onClose(); } catch (e: unknown) { diff --git a/app/components-react/windows/stream-avatar-automations/AutomationsAnalytics.ts b/app/components-react/windows/stream-avatar-automations/AutomationsAnalytics.ts new file mode 100644 index 000000000000..8a3e664e51d6 --- /dev/null +++ b/app/components-react/windows/stream-avatar-automations/AutomationsAnalytics.ts @@ -0,0 +1,21 @@ +import { Services } from 'components-react/service-provider'; + +type TAutomationsAction = + | 'page_view' + | 'template_added' + | 'automation_created' + | 'automation_updated' + | 'automation_fired'; + +export const AutomationsAnalytics = { + track(action: TAutomationsAction, payload?: Record) { + Services.UsageStatisticsService.recordAnalyticsEvent('Automations', { action, ...payload }); + }, + pageView: () => AutomationsAnalytics.track('page_view'), + templateAdded: (game: string, trigger: string, actions: string[]) => + AutomationsAnalytics.track('template_added', { game, trigger, actions }), + automationCreated: (game: string, trigger: string, actions: string[]) => + AutomationsAnalytics.track('automation_created', { game, trigger, actions }), + automationUpdated: (game: string, trigger: string, actions: string[]) => + AutomationsAnalytics.track('automation_updated', { game, trigger, actions }), +}; diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index e03368bc1370..8302a7797e0c 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -12,6 +12,7 @@ import { validateAutomation } from 'services/stream-avatar/engine/validation'; import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; import AutomationEditor from './AutomationEditor'; import PreMadeAutomations from './PreMadeAutomations'; +import { AutomationsAnalytics } from './AutomationsAnalytics'; import styles from './EditAutomations.m.less'; function conditionLabel(condition: { type: string } | null) { @@ -65,6 +66,7 @@ export default function EditAutomations() { const [simulatingId, setSimulatingId] = useState(null); useEffect(() => { + AutomationsAnalytics.pageView(); AutomationsService.actions.fetchAll(); }, []); diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx index 02ffad857e1c..ceb885f3ed26 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx @@ -5,6 +5,7 @@ import { Services } from 'components-react/service-provider'; import { $t } from 'services/i18n'; import type { ConditionType } from 'services/stream-avatar/engine/conditions'; import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; +import { AutomationsAnalytics } from './AutomationsAnalytics'; import styles from './PreMadeAutomations.m.less'; interface PreMadeItem { @@ -272,6 +273,11 @@ export default function PreMadeAutomations({ onClose }: Props) { } await AutomationsService.actions.create(item.automation); + AutomationsAnalytics.templateAdded( + item.automation.conditions[0]?.type.split('.')[0] ?? 'unknown', + item.automation.conditions[0]?.type ?? 'unknown', + item.automation.actions.map(a => a.type), + ); } } finally { setSaving(false); diff --git a/app/services/stream-avatar/automations-engine-service.ts b/app/services/stream-avatar/automations-engine-service.ts index d38a8a479d54..918cb523bb3e 100644 --- a/app/services/stream-avatar/automations-engine-service.ts +++ b/app/services/stream-avatar/automations-engine-service.ts @@ -6,6 +6,7 @@ import { WebsocketService } from 'services/websocket'; import { VisionService, VisionProcess } from 'services/vision'; import { AutomationsService } from './automations-service'; import { AgentSocketService } from './agent-socket-service'; +import { UsageStatisticsService } from 'services/usage-statistics'; import Utils from 'services/utils'; import { toUpper } from 'lodash'; import { @@ -36,6 +37,7 @@ export class AutomationsEngineService extends Service { @Inject() private visionService: VisionService; @Inject() private automationsService: AutomationsService; @Inject() private agentSocketService: AgentSocketService; + @Inject() private usageStatisticsService: UsageStatisticsService; private gameState: GameState = { ...defaultGameState, pendingEvents: new Set() }; private prevState: GameState = { ...defaultGameState, pendingEvents: new Set() }; @@ -259,6 +261,13 @@ export class AutomationsEngineService extends Service { const conditionsNotMet = conditionResults.some(r => !r.status); + this.usageStatisticsService.recordAnalyticsEvent('Automations', { + action: 'automation_fired', + game: currentGame, + trigger: conditionResults[0]?.condition.type ?? 'unknown', + actions: automation.actions.map(a => a.type), + }); + for (const exportedAction of automation.actions) { try { const [action] = await Actions.fromExported([exportedAction as any], this.actionContext); diff --git a/app/services/usage-statistics.ts b/app/services/usage-statistics.ts index fd038c6138f9..10d7cfd3de65 100644 --- a/app/services/usage-statistics.ts +++ b/app/services/usage-statistics.ts @@ -60,7 +60,8 @@ export type TAnalyticsEvent = | 'Onboarding' | 'WidgetAdded' | 'WidgetRemoved' - | 'GamePulse'; + | 'GamePulse' + | 'Automations'; // Refls are used as uuids for ultra components and should be updated for new ulta components. export type TUltraRefl = From f7397fc9297acdedadd526dbd15f8b901fac9017 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Thu, 9 Jul 2026 08:46:10 -0700 Subject: [PATCH 24/79] feat(automations): enhance UI layout and functionality for pre-made automations --- .../PreMadeAutomations.m.less | 298 ++++++++--- .../PreMadeAutomations.tsx | 466 ++++++++---------- .../stream-avatar/agent-socket-service.ts | 19 + .../stream-avatar/automations-service.ts | 6 +- 4 files changed, 475 insertions(+), 314 deletions(-) diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less index 0882117ced63..3609471cdb2c 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less @@ -2,102 +2,228 @@ display: flex; flex-direction: column; align-items: center; - padding: 8px 0 8px; + padding: 16px; overflow: hidden; } -.gameIcon { - width: 56px; - height: 56px; - border-radius: 50%; - background: var(--icon-active); +.mainRow { display: flex; - align-items: center; - justify-content: center; - font-size: 24px; - color: #000; - margin-bottom: 12px; + gap: 16px; + width: 100%; + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + background: #353e47; } -.title { - margin: 0 0 20px; - font-size: 32px; - font-weight: 400; - color: var(--title); - text-align: center; +// Left panel — game cover +.coverPanel { + position: relative; + width: 320px; + height: 260px; + flex-shrink: 0; + border-radius: 6px; + overflow: hidden; + background: var(--section); +} + +.coverVideo { + width: 100%; + height: 100%; + object-fit: cover; + display: block; } -.featured { +.coverTopOverlay { + position: absolute; + top: 10px; + left: 10px; display: flex; align-items: center; - margin-bottom: 16px; + gap: 6px; + background: #09161d8c; + border-radius: 10px; + padding: 6px 10px; } -.card { - border-radius: 8px; - overflow: hidden; - border: 1px solid var(--border); +.coverBottomOverlay { + position: absolute; + bottom: 10px; + left: 10px; + color: #fff; + font-size: 12px; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); } -.preview { - width: 539px; - height: 300px; - overflow: hidden; - background: var(--section); +.badge { + width: 26px; + height: 26px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + color: #fff; } -.previewVideo { - width: 100%; - height: 100%; - object-fit: cover; - display: block; +.coverGameName { + color: #fff; + font-size: 16px; + font-weight: 700; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); +} + +// Right panel — checklist +.checklistPanel { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; } -.infoRow { +.checklistHeader { display: flex; align-items: center; justify-content: space-between; - padding: 12px 16px; - background: var(--section); - gap: 12px; + font-size: 13px; + color: var(--title); + margin-bottom: 8px; + + a { + color: var(--teal); + cursor: pointer; + } +} + +.checklist { + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; +} + +.checklistRow { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + + &:hover { + background: #80f5d21a; + border-color: #80f5d2; + } +} + +.checklistRowActive { + border-color: #80f5d2; + background: #80f5d21a; +} + +.rowThumb { + width: 64px; + height: 40px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; } -.itemTitle { - font-size: 15px; +.rowText { + flex: 1; + min-width: 0; +} + +.rowTitle { + font-size: 13px; font-weight: 700; color: var(--title); - margin-bottom: 2px; } -.itemDesc { - font-size: 12px; +.rowDesc { + font-size: 11px; color: var(--paragraph); } -.thumbnailStrip { +.rowCheck { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid #ffffff80; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: #000; + font-size: 12px; +} + +.rowCheckActive { + border-color: #80f5d2; + background: #80f5d2; +} + +// Bottom — game carousel +.gameCards { display: flex; - gap: 8px; - margin-bottom: 10px; + justify-content: center; + gap: 10px; + width: 100%; overflow-x: auto; - max-width: 100%; + margin-bottom: 8px; } -.thumbnail { - width: 175px; - height: 86px; - border-radius: 4px; - border: 2px solid transparent; - cursor: pointer; +// ponytail: border-width stays 3px in every state (only border-color +// toggles) so the box model never changes size — that's what stops the +// hover jump. background-clip: padding-box keeps `.gameCard`'s own +// background from painting under the border ring, so a transparent border +// is genuinely invisible instead of showing as a dark band. +.gameCard { + width: 230px; + height: 78px; + box-sizing: border-box; flex-shrink: 0; + border-radius: 10px; + border: 3px solid transparent; + background: var(--section); + background-clip: padding-box; overflow: hidden; - position: relative; + cursor: pointer; &:hover { - border-color: var(--icon-active); + border-color: #ffffff; + } +} + +.gameCardActive { + border-color: #ffffff; +} + +.gameCardThumb { + position: relative; + width: 100%; + height: 100%; + + &::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + background: linear-gradient( + 0deg, + rgba(9, 22, 29, 0.92) 22.6%, + rgba(19, 23, 26, 0.6) 55%, + rgba(9, 22, 29, 0) 100% + ); } } -.thumbnailVideo { +.gameCardVideo { width: 100%; height: 100%; object-fit: cover; @@ -105,8 +231,66 @@ pointer-events: none; } -.thumbnailActive { - border-color: var(--icon-active); +.gameCardBadge { + position: absolute; + top: 50%; + left: 8px; + transform: translateY(-50%); + width: 26px; + height: 26px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + color: #fff; + z-index: 2; +} + +.gameCardName { + position: absolute; + top: 50%; + left: 40px; + transform: translateY(-50%); + color: #fff; + font-size: 16px; + font-weight: 700; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); + z-index: 2; +} + +// ponytail: antd's own `.ant-switch { position: relative }` has the same +// specificity as a plain `.gameCardSwitch { position: absolute }` and can win +// depending on stylesheet injection order — which knocks the switch out of +// flow and lets `.gameCard`'s `overflow: hidden` clip it entirely. Pairing our +// class with `:global(.ant-switch)` guarantees higher specificity so our +// positioning always wins; colors are left to the app's default theme. +.gameCardSwitch { + &:global(.ant-switch) { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + background: rgba(9, 22, 29, 0.7); + box-shadow: inset 0 0 0 1px #ffffff80; + } + + :global(.ant-switch-handle)::before { + background: #ffffff; + } +} + +.gameCardSub { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 6px 8px; + font-size: 12px; + color: #e3e8eb; + text-align: center; + z-index: 2; } .dots { diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx index ceb885f3ed26..2e332960f400 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx @@ -1,133 +1,24 @@ -import React, { useState } from 'react'; -import { Button, Switch } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { Button, Spin, Switch } from 'antd'; +import cx from 'classnames'; import { ModalLayout } from 'components-react/shared/ModalLayout'; import { Services } from 'components-react/service-provider'; import { $t } from 'services/i18n'; -import type { ConditionType } from 'services/stream-avatar/engine/conditions'; -import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; +import type { + AutomationTemplateGame, + AutomationTemplateItem, +} from 'services/stream-avatar/agent-socket-service'; import { AutomationsAnalytics } from './AutomationsAnalytics'; import styles from './PreMadeAutomations.m.less'; -interface PreMadeItem { - title: string; - description: string; - gameName: string; - color: string; - /** CDN video URL shown in the carousel preview */ - src: string; - /** When set, a hidden ffmpeg_source is created in the active scene */ - source?: { - name: string; - assetKey: string; - downloadUrl: string; - loop: boolean; - }; - automation: Omit; +// ponytail: badge color is a deterministic hash of the game name, not a server +// field — good enough for a letter badge without inventing a color-config surface. +const BADGE_COLORS = ['#7c5cff', '#f97316', '#22c55e', '#ef4444', '#06b6d4', '#eab308']; +function badgeColor(name: string): string { + const hash = name.split('').reduce((h, c) => h + c.charCodeAt(0), 0); + return BADGE_COLORS[hash % BADGE_COLORS.length]; } -const PRE_MADE: PreMadeItem[] = [ - { - title: 'Victory Royale', - description: 'Co-host comments on each game win.', - gameName: 'Fortnite', - color: '#1a3a5c', - src: 'https://cdn-avatar-builds.streamlabs.com/assets/victory-royale-celebration.webm', - source: { - name: 'victory-royale', - assetKey: 'victory-royale-celebration-animation.webm', - downloadUrl: - 'https://cdn-avatar-builds.streamlabs.com/assets/victory-royale-celebration-animation.webm', - loop: false, - }, - automation: { - description: 'Victory Royale', - enabled: true, - conditions: [{ type: 'fortnite.victory_royale' as ConditionType }], - actions: [ - { type: 'common.show_source', props: { source: { name: 'victory-royale' } } }, - { type: 'co-host.comment' }, - { type: 'common.wait_for_ms', props: { duration: 5000 } }, - { type: 'common.hide_source', props: { source: { name: 'victory-royale' } } }, - ], - }, - }, - { - title: 'Enemy Eliminated', - description: 'Co-host comments on enemy elimination.', - gameName: 'Fortnite', - color: '#2d4a1e', - src: 'https://cdn-avatar-builds.streamlabs.com/assets/player-killed.webm', - source: { - name: 'player-killed', - assetKey: 'player-killed-animation.webm', - downloadUrl: 'https://cdn-avatar-builds.streamlabs.com/assets/player-killed-animation.webm', - loop: false, - }, - automation: { - description: 'Enemy Eliminated', - enabled: true, - conditions: [{ type: 'fortnite.elimination' as ConditionType }], - actions: [ - { type: 'common.show_source', props: { source: { name: 'player-killed' } } }, - { type: 'co-host.comment' }, - { type: 'common.wait_for_ms', props: { duration: 5000 } }, - { type: 'common.hide_source', props: { source: { name: 'player-killed' } } }, - ], - }, - }, - { - title: 'Low Player Health', - description: 'Co-host comments when player health is low.', - gameName: 'Fortnite', - color: '#3a1a1a', - src: 'https://cdn-avatar-builds.streamlabs.com/assets/low-player-health.webm', - source: { - name: 'low-player-health', - assetKey: 'low-player-health-animation', - downloadUrl: - 'https://cdn-avatar-builds.streamlabs.com/assets/low-player-health-animation.webm', - loop: true, - }, - automation: { - description: 'Low Player Health', - enabled: true, - conditions: [{ type: 'fortnite.low_health' as ConditionType }], - actions: [ - { - type: 'common.show_source', - props: { source: { name: 'low-player-health' }, hide_if_condition_false: true }, - }, - { type: 'co-host.comment' }, - ], - }, - }, - { - title: 'Player Eliminated', - description: 'Co-host comments when you are eliminated.', - gameName: 'Fortnite', - color: '#2a1a3a', - src: 'https://cdn-avatar-builds.streamlabs.com/assets/player-eliminated.webm', - source: { - name: 'player-eliminated', - assetKey: 'player-eliminated-animation.webm', - downloadUrl: - 'https://cdn-avatar-builds.streamlabs.com/assets/player-eliminated-animation.webm', - loop: false, - }, - automation: { - description: 'Player Eliminated', - enabled: true, - conditions: [{ type: 'fortnite.player_eliminated' as ConditionType }], - actions: [ - { type: 'common.show_source', props: { source: { name: 'player-eliminated' } } }, - { type: 'co-host.comment' }, - { type: 'common.wait_for_ms', props: { duration: 3000 } }, - { type: 'common.hide_source', props: { source: { name: 'player-eliminated' } } }, - ], - }, - }, -]; - async function downloadAsset(downloadUrl: string, assetKey: string): Promise { try { const os = require('os') as typeof import('os'); @@ -138,13 +29,11 @@ async function downloadAsset(downloadUrl: string, assetKey: string): Promise', savePath); const response = await fetch(downloadUrl); if (!response.ok) throw new Error(`HTTP ${response.status}`); const buffer = await response.arrayBuffer(); - fs.writeFileSync(savePath, Buffer.from(buffer)); - console.log('[downloadAsset] saved', savePath); + fs.writeFileSync(savePath, new Uint8Array(buffer)); return savePath; } catch (e: unknown) { console.error('[downloadAsset] failed:', e); @@ -159,70 +48,35 @@ async function createSourceIfNeeded( loop: boolean, assets: string[], ) { - console.log('[createSourceIfNeeded] start', { - sourceName, - assetKey, - loop, - assetCount: assets.length, - }); - const { ScenesService, SourcesService } = Services; const activeScene = ScenesService.views.activeScene; - if (!activeScene) { - console.warn('[createSourceIfNeeded] no active scene, aborting'); - return; - } - console.log('[createSourceIfNeeded] active scene:', activeScene.id, activeScene.name); + if (!activeScene) return; // Dedup: skip if a source with this name is already in the active scene const existingSource = SourcesService.views.sources.find(s => s.name === sourceName); - console.log( - '[createSourceIfNeeded] existing source:', - existingSource ? existingSource.sourceId : 'none', - ); if (existingSource) { const sceneItems = activeScene.getItems(); const inScene = sceneItems.some((item: any) => item.sourceId === existingSource.sourceId); - console.log( - '[createSourceIfNeeded] already in scene:', - inScene, - '| scene item count:', - sceneItems.length, - ); if (inScene) return; } let assetPath = assets.find(a => a.includes(assetKey)); - console.log( - '[createSourceIfNeeded] asset path:', - assetPath ?? 'NOT FOUND', - '| searched key:', - assetKey, - ); - if (!assetPath) { - console.log('[createSourceIfNeeded] asset not found locally, downloading from:', downloadUrl); assetPath = (await downloadAsset(downloadUrl, assetKey)) ?? undefined; - if (!assetPath) { - console.warn('[createSourceIfNeeded] download failed, aborting'); - return; - } + if (!assetPath) return; } - console.log('[createSourceIfNeeded] creating source:', sourceName, 'at', assetPath); const sceneItemId = await ScenesService.actions.return.createAndAddSource( activeScene.id, sourceName, 'ffmpeg_source', { local_file: assetPath, loop }, ); - console.log('[createSourceIfNeeded] scene item id:', sceneItemId); if (sceneItemId) { const scene = ScenesService.views.getScene(activeScene.id); const sceneItem = scene?.getItem(sceneItemId); sceneItem?.setVisibility(false); - console.log('[createSourceIfNeeded] visibility set to hidden'); } } @@ -231,53 +85,84 @@ interface Props { } export default function PreMadeAutomations({ onClose }: Props) { - const { AutomationsService } = Services; - const [currentIndex, setCurrentIndex] = useState(0); - const [enabledSet, setEnabledSet] = useState>(new Set()); + const { AutomationsService, AgentSocketService } = Services; + const [games, setGames] = useState([]); + const [loading, setLoading] = useState(true); + const [activeGameIndex, setActiveGameIndex] = useState(0); + const [selections, setSelections] = useState>>({}); const [saving, setSaving] = useState(false); - function toggleItem(index: number) { - setEnabledSet(prev => { - const next = new Set(prev); - if (next.has(index)) next.delete(index); - else next.add(index); - return next; - }); + useEffect(() => { + AgentSocketService.getAutomationTemplates() + .then(setGames) + .finally(() => setLoading(false)); + }, []); + + const activeGame: AutomationTemplateGame | undefined = games[activeGameIndex]; + const activeSelection = (activeGame && selections[activeGame.game]) ?? new Set(); + + function setGameSelection(gameKey: string, next: Set) { + setSelections(prev => ({ ...prev, [gameKey]: next })); + } + + function toggleTemplate(index: number) { + if (!activeGame) return; + const next = new Set(activeSelection); + if (next.has(index)) next.delete(index); + else next.add(index); + setGameSelection(activeGame.game, next); } - function goTo(index: number) { - setCurrentIndex(index); + function toggleSelectAll() { + if (!activeGame) return; + const allSelected = activeSelection.size === activeGame.templates.length; + setGameSelection( + activeGame.game, + allSelected ? new Set() : new Set(activeGame.templates.map((_, i) => i)), + ); + } + + function toggleGameSwitch(game: AutomationTemplateGame) { + const current = selections[game.game]?.size ?? 0; + setGameSelection(game.game, current > 0 ? new Set() : new Set(game.templates.map((_, i) => i))); } + const totalSelected = Object.values(selections).reduce((sum, set) => sum + set.size, 0); + async function handleComplete() { setSaving(true); try { const assets: string[] = (await (window as any)?.streamlabsOBS?.v1?.NativeComponents?.getAssets?.()) ?? []; - for (const index of enabledSet) { - const item = PRE_MADE[index]; + for (const game of games) { + const indices = selections[game.game]; + if (!indices || indices.size === 0) continue; - if (item.source) { - try { - await createSourceIfNeeded( - item.source.name, - item.source.assetKey, - item.source.downloadUrl, - item.source.loop, - assets, - ); - } catch { - // non-fatal — continue creating the automation + for (const index of indices) { + const item: AutomationTemplateItem = game.templates[index]; + + if (item.source) { + try { + await createSourceIfNeeded( + item.source.name, + item.source.assetKey, + item.source.downloadUrl, + item.source.loop, + assets, + ); + } catch { + // non-fatal — continue creating the automation + } } - } - await AutomationsService.actions.create(item.automation); - AutomationsAnalytics.templateAdded( - item.automation.conditions[0]?.type.split('.')[0] ?? 'unknown', - item.automation.conditions[0]?.type ?? 'unknown', - item.automation.actions.map(a => a.type), - ); + await AutomationsService.actions.create(item.automation); + AutomationsAnalytics.templateAdded( + game.game, + item.automation.conditions[0]?.type ?? 'unknown', + item.automation.actions.map(a => a.type), + ); + } } } finally { setSaving(false); @@ -285,8 +170,6 @@ export default function PreMadeAutomations({ onClose }: Props) { onClose(); } - const current = PRE_MADE[currentIndex]; - const footer = ( <> ); @@ -306,66 +191,139 @@ export default function PreMadeAutomations({ onClose }: Props) { return (
-
- -
- -

{$t('Select Pre-made Automation')}

- -
-
-
-
-
-
-
{current.title}
-
{current.description}
+ {loading || !activeGame ? ( + + ) : ( + <> +
+
+
- toggleItem(currentIndex)} - /> -
-
-
-
- {PRE_MADE.map((item, i) => ( -
goTo(i)} - > -
- ))} -
-
- {PRE_MADE.map((_, i) => ( - goTo(i)} - /> - ))} -
+ {games.length > 1 && ( + <> +
+ {games.map((game, i) => { + const selectedCount = selections[game.game]?.size ?? 0; + return ( +
setActiveGameIndex(i)} + > +
+
+
+ ); + })} +
+
+ {games.map((_, i) => ( + setActiveGameIndex(i)} + /> + ))} +
+ + )} + + )}
); diff --git a/app/services/stream-avatar/agent-socket-service.ts b/app/services/stream-avatar/agent-socket-service.ts index 407b4302365d..d03dc892c2b0 100644 --- a/app/services/stream-avatar/agent-socket-service.ts +++ b/app/services/stream-avatar/agent-socket-service.ts @@ -5,6 +5,7 @@ import { HostsService } from 'services/hosts'; import { UserService } from 'services/user'; import { importSocketIOClient } from 'util/slow-imports'; import Utils from 'services/utils'; +import type { TAutomationExport } from './engine/automations'; interface SocketAck { ok: boolean; @@ -12,6 +13,20 @@ interface SocketAck { error?: string; } +export interface AutomationTemplateItem { + title: string; + description: string; + videoUrl: string; + source?: { name: string; assetKey: string; downloadUrl: string; loop: boolean }; + automation: Omit; +} + +export interface AutomationTemplateGame { + game: string; + gameName: string; + templates: AutomationTemplateItem[]; +} + export type TAgentSocketStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; interface IAgentSocketState { @@ -231,6 +246,10 @@ export class AgentSocketService extends StatefulService { return this.call('getAutomations'); } + getAutomationTemplates(): Promise { + return this.call('getAutomationTemplates'); + } + createAutomation(automation: any): Promise { return this.call('createAutomation', automation); } diff --git a/app/services/stream-avatar/automations-service.ts b/app/services/stream-avatar/automations-service.ts index 7556c83510a3..62f1c54c79de 100644 --- a/app/services/stream-avatar/automations-service.ts +++ b/app/services/stream-avatar/automations-service.ts @@ -62,7 +62,7 @@ export class AutomationsService extends StatefulService { title: $t('Automations'), size: { width: 900, - height: 750, + height: 625, }, }); } @@ -73,7 +73,7 @@ export class AutomationsService extends StatefulService { title: $t('Automations'), size: { width: 900, - height: 750, + height: 625, }, queryParams: { editAutomationId: id }, }); @@ -85,7 +85,7 @@ export class AutomationsService extends StatefulService { title: $t('Automations'), size: { width: 900, - height: 750, + height: 625, }, queryParams: { createNew: true }, }); From 0660ac54bdd8bffe84a7c3ce6c5c183e7e66deaa Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Thu, 9 Jul 2026 09:30:56 -0700 Subject: [PATCH 25/79] feat(automations): improve localization and dynamic labels in automation components --- .../EditAutomations.tsx | 4 +--- .../PreMadeAutomations.tsx | 18 ++++++++++++------ app/i18n/en-US/stream-avatar-automations.json | 9 ++++++++- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index 8302a7797e0c..c2cb9221eb5b 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -349,9 +349,7 @@ export default function EditAutomations() { okText={$t('Delete')} cancelText={$t('Cancel')} > - - - +
diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx index 2e332960f400..bb90f595798e 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx @@ -170,6 +170,11 @@ export default function PreMadeAutomations({ onClose }: Props) { onClose(); } + const addLabel = + totalSelected === 1 + ? $t('Add %{count} Automation', { count: totalSelected }) + : $t('Add %{count} Automations', { count: totalSelected }); + const footer = ( <> ); @@ -216,7 +219,7 @@ export default function PreMadeAutomations({ onClose }: Props) { {activeGame.gameName}
- {activeGame.templates.length} automations + {$t('%{count} automations', { count: activeGame.templates.length })}
@@ -303,8 +306,11 @@ export default function PreMadeAutomations({ onClose }: Props) { />
{selectedCount > 0 - ? `${selectedCount} of ${game.templates.length} added` - : `${game.templates.length} automations`} + ? $t('%{count} of %{total} added', { + count: selectedCount, + total: game.templates.length, + }) + : $t('%{count} automations', { count: game.templates.length })}
diff --git a/app/i18n/en-US/stream-avatar-automations.json b/app/i18n/en-US/stream-avatar-automations.json index c8c5f2d95abb..94b6180752c2 100644 --- a/app/i18n/en-US/stream-avatar-automations.json +++ b/app/i18n/en-US/stream-avatar-automations.json @@ -80,5 +80,12 @@ "Source \"%{name}\" is unavailable.": "Source \"%{name}\" is unavailable.", "Enter an instruction for the co-host.": "Enter an instruction for the co-host.", "Instruction must be %{max} characters or fewer.": "Instruction must be %{max} characters or fewer.", - "Requires the Intelligent Streaming Agent app to be installed and enabled.": "Requires the Intelligent Streaming Agent app to be installed and enabled." + "Requires the Intelligent Streaming Agent app to be installed and enabled.": "Requires the Intelligent Streaming Agent app to be installed and enabled.", + "What your co-host will react to": "What your co-host will react to", + "Select all": "Select all", + "Unselect all": "Unselect all", + "%{count} automations": "%{count} automations", + "%{count} of %{total} added": "%{count} of %{total} added", + "Add %{count} Automation": "Add %{count} Automation", + "Add %{count} Automations": "Add %{count} Automations" } From 9e752328c9a2a493b7cf48163db0bb2d8b19caef Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Fri, 10 Jul 2026 07:21:39 -0700 Subject: [PATCH 26/79] feat(automations): refactor AutomationEditor and EditAutomations components, add PreMadeAutomationsFooter for improved UI and functionality --- .../AutomationEditor.tsx | 18 +- .../EditAutomations.m.less | 38 --- .../EditAutomations.tsx | 75 ++--- .../PreMadeAutomations.m.less | 45 ++- .../PreMadeAutomations.tsx | 309 +++++++++--------- .../PreMadeAutomationsFooter.tsx | 38 +++ app/i18n/en-US/stream-avatar-automations.json | 11 +- .../stream-avatar/automations-service.ts | 6 +- 8 files changed, 257 insertions(+), 283 deletions(-) create mode 100644 app/components-react/windows/stream-avatar-automations/PreMadeAutomationsFooter.tsx diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index 649f0a903611..570f92ede23e 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -398,10 +398,9 @@ function ActionEditor({ interface Props { initial?: TAutomationExport; onClose: () => void; - onViewTemplates?: () => void; } -export default function AutomationEditor({ initial, onClose, onViewTemplates }: Props) { +export default function AutomationEditor({ initial, onClose }: Props) { const { AutomationsService, ScenesService, SourcesService } = Services; const { isInstalled: isAgentInstalled, @@ -591,21 +590,6 @@ export default function AutomationEditor({ initial, onClose, onViewTemplates }:

{initial ? $t('Edit Automation') : $t('Add New Automation')}

- {onViewTemplates && ( - - )}
diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less b/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less index 9f51773125a2..739425a16a9c 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less @@ -155,41 +155,3 @@ white-space: nowrap; } -// ── Empty state ─────────────────────────────────────── - -.emptyCard { - border: 1px dashed var(--border); - border-radius: 8px; - padding: 40px 24px; - text-align: center; - margin-top: 8px; -} - -.emptyImage { - width: 260px; - height: 160px; - background: var(--section); - border-radius: 8px; - margin: 0 auto 24px; -} - -.emptyTitle { - margin: 0 0 10px; - font-size: 16px; - font-weight: 700; - color: var(--title); -} - -.emptyDesc { - margin: 0 auto 24px; - max-width: 480px; - font-size: 13px; - color: var(--paragraph); - line-height: 1.5; -} - -.emptyActions { - display: flex; - justify-content: center; - gap: 12px; -} diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index c2cb9221eb5b..2a16d8d853c8 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -12,6 +12,7 @@ import { validateAutomation } from 'services/stream-avatar/engine/validation'; import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; import AutomationEditor from './AutomationEditor'; import PreMadeAutomations from './PreMadeAutomations'; +import PreMadeAutomationsFooter from './PreMadeAutomationsFooter'; import { AutomationsAnalytics } from './AutomationsAnalytics'; import styles from './EditAutomations.m.less'; @@ -42,6 +43,8 @@ const GAME_FILTER_OPTIONS = Object.entries(GAME_NAMES) .map(([id, name]) => ({ label: name, value: id })) .sort((a, b) => a.label.localeCompare(b.label)); +type TPreMadeFooterState = { totalSelected: number; saving: boolean; onComplete: () => void }; + export default function EditAutomations() { const { AutomationsService, @@ -64,6 +67,7 @@ export default function EditAutomations() { const [showPreMade, setShowPreMade] = useState(false); const [filterGame, setFilterGame] = useState(''); const [simulatingId, setSimulatingId] = useState(null); + const [preMadeFooter, setPreMadeFooter] = useState(null); useEffect(() => { AutomationsAnalytics.pageView(); @@ -149,20 +153,16 @@ export default function EditAutomations() { } if (creating || editingAutomation) { - return ( - { - closeEditor(); - setShowPreMade(true); - }} - /> - ); + return ; } if (showPreMade) { - return setShowPreMade(false)} />; + return ( + setShowPreMade(false)} + onSaved={() => setShowPreMade(false)} + /> + ); } const filtered = filterGame @@ -179,19 +179,27 @@ export default function EditAutomations() { {$t('Add New Automation')} setShowPreMade(true)}> - {$t('Select from Pre-made')} + {$t('Use Template')} ); - const footer = ( - <> - - - - ); + const footer = + automations.length === 0 && preMadeFooter ? ( + + ) : ( + <> + + + + ); return ( @@ -239,32 +247,7 @@ export default function EditAutomations() { )} {loaded && automations.length === 0 && ( -
-
-

{$t("You don't have Automations set up yet.")}

-

- {$t( - 'Automatically trigger on stream effects including visual effects, transitions, sounds, agent commentary (and more) in response to gameplay events such as kills, wins, knocks, deaths, and much more. See supported games.', - )} -

-
- - -
-
+ )} {loaded && automations.length > 0 && filtered.length === 0 && ( diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less index 3609471cdb2c..3c8a7f441388 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less @@ -6,6 +6,13 @@ overflow: hidden; } +// Embedded in EditAutomations, which already applies its own body/header +// spacing — the standalone padding above would just stack on top of that +// and push the modal past its fixed scrollable height. +.containerEmbedded { + padding: 0; +} + .mainRow { display: flex; gap: 16px; @@ -260,25 +267,27 @@ z-index: 2; } -// ponytail: antd's own `.ant-switch { position: relative }` has the same -// specificity as a plain `.gameCardSwitch { position: absolute }` and can win -// depending on stylesheet injection order — which knocks the switch out of -// flow and lets `.gameCard`'s `overflow: hidden` clip it entirely. Pairing our -// class with `:global(.ant-switch)` guarantees higher specificity so our -// positioning always wins; colors are left to the app's default theme. -.gameCardSwitch { - &:global(.ant-switch) { - position: absolute; - top: 8px; - right: 8px; - z-index: 2; - background: rgba(9, 22, 29, 0.7); - box-shadow: inset 0 0 0 1px #ffffff80; - } +.gameCardCheck { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid #ffffff80; + background: rgba(9, 22, 29, 0.7); + display: flex; + align-items: center; + justify-content: center; + color: #000; + font-size: 12px; + cursor: pointer; +} - :global(.ant-switch-handle)::before { - background: #ffffff; - } +.gameCardCheckActive { + border-color: #ffffff; + background: #ffffff; } .gameCardSub { diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx index bb90f595798e..04e33b3a1f99 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Button, Spin, Switch } from 'antd'; +import { Spin } from 'antd'; import cx from 'classnames'; import { ModalLayout } from 'components-react/shared/ModalLayout'; import { Services } from 'components-react/service-provider'; @@ -9,6 +9,7 @@ import type { AutomationTemplateItem, } from 'services/stream-avatar/agent-socket-service'; import { AutomationsAnalytics } from './AutomationsAnalytics'; +import PreMadeAutomationsFooter from './PreMadeAutomationsFooter'; import styles from './PreMadeAutomations.m.less'; // ponytail: badge color is a deterministic hash of the game name, not a server @@ -81,10 +82,17 @@ async function createSourceIfNeeded( } interface Props { - onClose: () => void; + onCancel: () => void; + onSaved?: () => void; + embedded?: boolean; + onFooterChange?: (footer: { + totalSelected: number; + saving: boolean; + onComplete: () => void; + }) => void; } -export default function PreMadeAutomations({ onClose }: Props) { +export default function PreMadeAutomations({ onCancel, onSaved, embedded, onFooterChange }: Props) { const { AutomationsService, AgentSocketService } = Services; const [games, setGames] = useState([]); const [loading, setLoading] = useState(true); @@ -122,13 +130,18 @@ export default function PreMadeAutomations({ onClose }: Props) { ); } - function toggleGameSwitch(game: AutomationTemplateGame) { + function toggleGameSelection(game: AutomationTemplateGame) { const current = selections[game.game]?.size ?? 0; setGameSelection(game.game, current > 0 ? new Set() : new Set(game.templates.map((_, i) => i))); } const totalSelected = Object.values(selections).reduce((sum, set) => sum + set.size, 0); + useEffect(() => { + onFooterChange?.({ totalSelected, saving, onComplete: handleComplete }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [totalSelected, saving]); + async function handleComplete() { setSaving(true); try { @@ -167,170 +180,160 @@ export default function PreMadeAutomations({ onClose }: Props) { } finally { setSaving(false); } - onClose(); + onSaved?.(); } - const addLabel = - totalSelected === 1 - ? $t('Add %{count} Automation', { count: totalSelected }) - : $t('Add %{count} Automations', { count: totalSelected }); - const footer = ( - <> - - - + ); - return ( - -
- {loading || !activeGame ? ( - - ) : ( - <> -
-
-
+ + + + + + + + + + {automations.map(automation => { + const issues = validateAutomation(automation, { scenes, sources }); + return ( + + + + + + + + ); + })} + +
{$t('Description')}{$t('When')}{$t('Do')}{$t('Game')} +
+ {automation.description || $t('(no description)')} + {automation.conditions.map(c => conditionLabel(c)).join(', ')}{summarizeActions(automation.actions)} + {automation.conditions.map((c, i) => { + const game = conditionGame(c); + return game ? ( + + {game} + + ) : null; + })} + +
+ {issues.length > 0 && ( + + {issues.map((issue, i) => ( +
{issue.message}
+ ))} +
+ } + > + + + )} + + toggleEnabled(automation)} + /> + + + {simulatingId === automation.id ? ( + + ) : ( + simulate(automation)} + /> + )} + + + edit(automation)} /> + + + remove(automation)} /> + + +
+ )} + + ); +} diff --git a/app/components/shared/ReactComponentList.tsx b/app/components/shared/ReactComponentList.tsx index 620990de29d0..acd2e6b867aa 100644 --- a/app/components/shared/ReactComponentList.tsx +++ b/app/components/shared/ReactComponentList.tsx @@ -73,6 +73,14 @@ export class EditStreamWindow extends ReactComponent {} }) export class EditTransform extends ReactComponent {} +@Component({ + props: { + name: { default: 'EditAutomations' }, + wrapperStyles: { default: () => ({ height: '100%' }) }, + }, +}) +export class EditAutomations extends ReactComponent {} + @Component({ props: { name: { default: 'GoLiveWindow' }, diff --git a/app/i18n/en-US/stream-avatar-automations.json b/app/i18n/en-US/stream-avatar-automations.json new file mode 100644 index 000000000000..9b7829a948e8 --- /dev/null +++ b/app/i18n/en-US/stream-avatar-automations.json @@ -0,0 +1,49 @@ +{ + "Edit Automations.": "Edit Automations.", + "New Automation": "New Automation", + "Edit Automation": "Edit Automation", + "Create Automation": "Create Automation", + "Delete this automation?": "Delete this automation?", + "You don't have any automations yet.": "You don't have any automations yet.", + "Loading...": "Loading...", + "Test automation": "Test automation", + "Enabled": "Enabled", + "Disabled": "Disabled", + "(unknown)": "(unknown)", + "(no description)": "(no description)", + "Description": "Description", + "When": "When", + "Do": "Do", + "Game": "Game", + "Edit": "Edit", + "Delete": "Delete", + "Back": "Back", + "Saving...": "Saving...", + "Save Changes": "Save Changes", + "When (Condition)": "When (Condition)", + "Do (Actions)": "Do (Actions)", + "No conditions available": "No conditions available", + "+ Add Action": "+ Add Action", + "e.g. Victory Royale reaction": "e.g. Victory Royale reaction", + "— select scene —": "— select scene —", + "— select source —": "— select source —", + "unavailable": "unavailable", + "Duration": "Duration", + "Instruction": "Instruction", + "Hide if condition is false": "Hide if condition is false", + "Show if condition is false": "Show if condition is false", + "The co-host will automatically comment based on the active game condition.": "The co-host will automatically comment based on the active game condition.", + "Please fix the highlighted fields before saving.": "Please fix the highlighted fields before saving.", + "Failed to save automation.": "Failed to save automation.", + "Add a description.": "Add a description.", + "Description must be %{max} characters or fewer.": "Description must be %{max} characters or fewer.", + "Select a condition.": "Select a condition.", + "This automation uses an unknown condition.": "This automation uses an unknown condition.", + "Add at least one action.": "Add at least one action.", + "Select a scene to switch to.": "Select a scene to switch to.", + "Scene \"%{name}\" no longer exists.": "Scene \"%{name}\" no longer exists.", + "Select a source.": "Select a source.", + "Source \"%{name}\" is unavailable.": "Source \"%{name}\" is unavailable.", + "Enter an instruction for the co-host.": "Enter an instruction for the co-host.", + "Instruction must be %{max} characters or fewer.": "Instruction must be %{max} characters or fewer." +} diff --git a/app/i18n/fallback.ts b/app/i18n/fallback.ts index f254975664d9..259914c60b46 100644 --- a/app/i18n/fallback.ts +++ b/app/i18n/fallback.ts @@ -71,6 +71,7 @@ const fallbackDictionary = { ...require('./en-US/developer.json'), ...require('./en-US/dual-output.json'), ...require('./en-US/patreon.json'), + ...require('./en-US/stream-avatar-automations.json'), }; export default fallbackDictionary; diff --git a/app/services/hosts.ts b/app/services/hosts.ts index bafc3e8c7c68..f186c72494b9 100644 --- a/app/services/hosts.ts +++ b/app/services/hosts.ts @@ -55,6 +55,16 @@ export class HostsService extends Service { get analitycs() { return 'r2d2.streamlabs.com'; } + + get streamAvatarApi() { + if (Util.shouldUseAvatarLocalHost()) { + return 'localhost:3000'; + } else if (Util.shouldUseBeta()) { + return 'ai-agent.streamlabs.com'; + } + + return 'isa.streamlabs.com'; + } } export class UrlService extends Service { diff --git a/app/services/stream-avatar/agent-socket-service.ts b/app/services/stream-avatar/agent-socket-service.ts new file mode 100644 index 000000000000..955e4dfdeec2 --- /dev/null +++ b/app/services/stream-avatar/agent-socket-service.ts @@ -0,0 +1,162 @@ +import { InitAfter, Inject, Service } from 'services/core'; +import { StreamAvatarApiService } from './stream-avatar-api-service'; +import { HostsService } from 'services/hosts'; +import { UserService } from 'services/user'; +import { importSocketIOClient } from 'util/slow-imports'; +import Utils from 'services/utils'; + +interface SocketAck { + ok: boolean; + data?: T; + error?: string; +} + +@InitAfter('UserService') +export class AgentSocketService extends Service { + @Inject() private streamAvatarApiService: StreamAvatarApiService; + @Inject() private hostsService: HostsService; + @Inject() private userService: UserService; + + private socket: SocketIOClient.Socket | null = null; + private readyPromise: Promise = Promise.resolve(); + private readyResolve: (() => void) | null = null; + + init() { + console.log('[AgentSocket] init() called. isWorkerWindow:', Utils.isWorkerWindow()); + if (!Utils.isWorkerWindow()) return; + this.resetReady(); + console.log('[AgentSocket] init() isLoggedIn:', this.userService.isLoggedIn); + if (this.userService.isLoggedIn) { + this.connect(); + } + this.userService.userLogin.subscribe(() => { + console.log('[AgentSocket] userLogin fired, reconnecting'); + this.resetReady(); + this.connect(); + }); + } + + private resetReady() { + this.readyPromise = new Promise(resolve => { + this.readyResolve = resolve; + }); + } + + private get socketUrl(): string { + const protocol = Utils.shouldUseAvatarLocalHost() ? 'http://' : 'https://'; + return `${protocol}${this.hostsService.streamAvatarApi}`; + } + + private async connect() { + try { + console.log('[AgentSocket] connect() start. URL:', this.socketUrl); + const io = (await importSocketIOClient()).default; + console.log('[AgentSocket] socket.io-client imported, requesting token...'); + const jwt = await this.streamAvatarApiService.getToken(); + console.log('[AgentSocket] token obtained, length:', jwt?.length); + + if (this.socket) { + this.socket.disconnect(); + } + + // NOTE: desktop bundles socket.io-client v2, which has no `auth` handshake + // field (added in v3). The token must be sent via `query`; the backend + // reads `socket.handshake.query.token` as a fallback. Requires the server + // to run with `allowEIO3: true`. + this.socket = io(this.socketUrl, { + autoConnect: false, + reconnection: true, + reconnectionDelay: 1000, + timeout: 20000, + transports: ['websocket'], + query: { token: jwt }, + } as any); + + this.socket.on('connect', () => { + console.log('[AgentSocket] socket "connect" event, id:', this.socket?.id); + }); + + this.socket.on('authenticated', (payload: any) => { + console.log('[AgentSocket] "authenticated" event received', payload); + this.readyResolve?.(); + }); + + this.socket.on('authError', (err: any) => { + console.error('[AgentSocket] "authError" event:', err); + }); + + this.socket.on('disconnect', (reason: any) => { + console.warn('[AgentSocket] "disconnect" event:', reason); + this.resetReady(); + }); + + this.socket.on('connect_error', async (err: any) => { + console.error('[AgentSocket] "connect_error" event:', err?.message, err); + if (err?.message?.includes('unauthorized') || err?.message?.includes('auth')) { + try { + const freshJwt = await this.streamAvatarApiService.getToken(true); + if (this.socket) { + // v2 client: update the query token (no `auth` field support) + (this.socket as any).io.opts.query = { token: freshJwt }; + this.socket.connect(); + } + } catch (e) { + console.error('[AgentSocket] token refresh on connect_error failed:', e); + } + } + }); + + console.log('[AgentSocket] calling socket.connect()...'); + this.socket.connect(); + } catch (e) { + console.error('[AgentSocket] connect() threw:', e); + } + } + + private async call(event: string, ...args: any[]): Promise { + console.log(`[AgentSocket] call("${event}") awaiting ready... socket connected:`, this.socket?.connected); + await this.readyPromise; + console.log(`[AgentSocket] call("${event}") ready resolved, emitting`); + return new Promise((resolve, reject) => { + this.socket!.emit(event, ...args, (res: SocketAck) => { + console.log(`[AgentSocket] call("${event}") ack received:`, res?.ok, res?.error); + if (res.ok) resolve(res.data as T); + else reject(new Error(res.error ?? `${event} failed`)); + }); + }); + } + + getAutomations(): Promise { + return this.call('getAutomations'); + } + + createAutomation(automation: any): Promise { + return this.call('createAutomation', automation); + } + + updateAutomation(automation: any): Promise { + return this.call('updateAutomation', automation); + } + + async deleteAutomation(id: number): Promise { + await this.call('deleteAutomation', id); + } + + sendInstruction(instruction: string, response: 'text' | 'tts' = 'tts') { + const message = { + type: 'instruction', + data: { instruction }, + response, + }; + this.socket?.emit('message', message); + } + + sendTrigger(name: string, parameters: Record, response: 'text' | 'tts' = 'tts') { + const message = { + type: 'trigger', + data: { trigger: { name, parameters } }, + response, + }; + this.socket?.emit('message', message); + } +} diff --git a/app/services/stream-avatar/automations-engine-service.ts b/app/services/stream-avatar/automations-engine-service.ts new file mode 100644 index 000000000000..666ce3935f81 --- /dev/null +++ b/app/services/stream-avatar/automations-engine-service.ts @@ -0,0 +1,312 @@ +import { InitAfter, Inject, Service } from 'services/core'; +import { ScenesService } from 'services/scenes'; +import { SourcesService } from 'services/sources'; +import { StreamingService } from 'services/streaming'; +import { WebsocketService } from 'services/websocket'; +import { VisionService, VisionProcess } from 'services/vision'; +import { AutomationsService } from './automations-service'; +import Utils from 'services/utils'; +import { toUpper } from 'lodash'; +import { + ConditionsManager, + GAME_NAMES, + TCondition, + TEvaluatedCondition, +} from './engine/conditions'; +import { Actions, ActionContext } from './engine/actions'; +import { GameState, defaultGameState } from './engine/game-state'; + +interface VisionEventItem { + name: string; + data: { value: number }; +} + +interface VisionEventPayload { + events: VisionEventItem[]; + game: string; +} + +@InitAfter('VisionService') +export class AutomationsEngineService extends Service { + @Inject() private scenesService: ScenesService; + @Inject() private sourcesService: SourcesService; + @Inject() private streamingService: StreamingService; + @Inject() private websocketService: WebsocketService; + @Inject() private visionService: VisionService; + @Inject() private automationsService: AutomationsService; + + private gameState: GameState = { ...defaultGameState, pendingEvents: new Set() }; + private prevState: GameState = { ...defaultGameState, pendingEvents: new Set() }; + private activeProcess: VisionProcess | null = null; + private selectedGame = 'fortnite'; + private automationPreviousConditionsMetCache = new Map(); + private actionContext!: ActionContext; + + init() { + // Engine runs only in the worker window to prevent double-firing. + if (!Utils.isWorkerWindow()) return; + + this.actionContext = { + resolveSceneId: async ref => { + const scenes = this.scenesService.views.scenes; + const scene = + 'id' in ref ? scenes.find(s => s.id === ref.id) : scenes.find(s => s.name === ref.name); + if (!scene) throw new Error(`Scene not found: ${JSON.stringify(ref)}`); + return { id: scene.id, name: scene.name }; + }, + + resolveSourceId: async ref => { + const source = + 'id' in ref + ? this.sourcesService.views.getSource(ref.id) + : this.sourcesService.views.getSourcesByName(ref.name)[0]; + if (!source) throw new Error(`Source not found: ${JSON.stringify(ref)}`); + return { id: source.sourceId, name: source.name }; + }, + + switchScene: (id: string) => { + this.scenesService.makeSceneActive(id); + }, + + setSourceVisible: (id: string, visible: boolean) => { + const activeScene = this.scenesService.views.activeScene; + if (!activeScene) return; + for (const item of activeScene.getItems().filter(i => i.sourceId === id)) { + item.setVisibility(visible); + } + }, + + saveReplay: async () => { + if (!this.streamingService.views.isReplayBufferActive) { + this.streamingService.startReplayBuffer(); + await new Promise(resolve => setTimeout(resolve, 500)); + } + this.streamingService.saveReplay(); + }, + }; + + this.websocketService.socketEvent.subscribe(e => { + if (e.type !== 'visionEvent') return; + this.handleVisionEvent((e as any).message as VisionEventPayload); + }); + + this.visionService.onGame.subscribe(change => { + this.activeProcess = change.activeProcess; + this.selectedGame = change.selectedGame; + this.resetGameState(); + }); + } + + /** + * Runs an automation's actions on demand so the user can test it from the + * list. Fires every action as if its conditions were met, waits, then fires + * again with conditions unmet so conditional show/hide actions revert. + */ + async simulateAutomation(id: number): Promise { + const automation = this.automationsService.state.automations.find(a => a.id === id); + if (!automation) { + console.warn('[AutomationsEngine] simulateAutomation: automation not found', id); + return; + } + + const runPass = async (conditionsMet: boolean) => { + for (const exportedAction of automation.actions) { + try { + const [action] = await Actions.fromExported([exportedAction as any], this.actionContext); + await Actions.process({ + conditionsMet, + conditions: automation.conditions as TCondition[], + action: { ...action, props: { ...action.props, simulating: true } }, + context: this.actionContext, + state: this.gameState, + prevState: this.prevState, + }); + } catch (error) { + console.warn('[AutomationsEngine] simulate action failed', { + action: exportedAction.type, + error, + }); + } + } + }; + + await runPass(true); + await new Promise(resolve => setTimeout(resolve, 5000)); + await runPass(false); + } + + private resetGameState() { + this.gameState = { ...defaultGameState, pendingEvents: new Set() }; + this.prevState = { ...defaultGameState, pendingEvents: new Set() }; + this.automationPreviousConditionsMetCache.clear(); + } + + private getCurrentGame(): string { + const process = this.activeProcess; + if (!process) return 'STREAMLABS'; + + if (process.type === 'capture_device' || process.executable_name === 'vlc.exe') { + return this.visionService.state.availableGames[this.selectedGame] + ? toUpper(this.selectedGame) + : 'STREAMLABS'; + } + + return this.visionService.state.availableGames[process.game] + ? toUpper(process.game) + : 'STREAMLABS'; + } + + private handleVisionEvent(payload: VisionEventPayload) { + if (!(payload.game in GAME_NAMES)) return; + + this.prevState = { ...this.gameState, pendingEvents: new Set(this.gameState.pendingEvents) }; + + const newEvents: string[] = []; + const next: Partial = {}; + + for (const { name, data } of payload.events) { + switch (name) { + case 'game_start': + case 'round_start': + next.eliminations = 0; + next.teamScore = 0; + next.opponentScore = 0; + newEvents.push('game_start'); + break; + + case 'round_end': + case 'game_end': + Object.assign(next, defaultGameState); + newEvents.push('game_end'); + break; + + case 'health': + next.health = data.value; + break; + + case 'shield': + next.shield = data.value; + break; + + case 'elimination': + next.eliminations = (this.gameState.eliminations || 0) + 1; + newEvents.push('elimination'); + break; + + case 'team_scored': + next.teamScore = data.value; + newEvents.push('team_scored'); + break; + + case 'opponent_scored': + next.opponentScore = data.value; + newEvents.push('opponent_scored'); + break; + + case 'low_health': + next.health = 20; + break; + + case 'full_health': + next.health = 100; + break; + + case 'spectating': + case 'action_phase': + case 'deploy': + case 'victory': + case 'death': + case 'defeat': + case 'knockout': + case 'player_knocked': + case 'redeploying': + case 'storm_shrinking': + case 'gulag_start': + case 'gulag_end': + case 'first_half': + case 'second_half': + case 'round_won': + case 'round_lost': + case 'enemy_spotted': + case 'enemy_detected': + case 'interesting_moment': + case 'ender_dragon_spawned': + case 'boss_killed': + case 'wither_spawned': + case 'advancement_made': + case 'first_diamond': + case 'nether_entered': + case 'totem_of_undying_used': + case 'player_revived': + case 'hooked_survivor': + case 'escaped': + case 'tower_destroyed': + case 'glyph_used': + case 'objective_ally': + case 'objective_enemy': + case 'enemy_turret_destroyed': + case 'ally_turret_destroyed': + case 'position_change': + case 'lap_change': + case 'goal': + case 'set_piece': + case 'halftime': + case 'fulltime': + newEvents.push(name); + break; + + default: + break; + } + } + + this.gameState = { ...this.gameState, ...next, pendingEvents: new Set(newEvents) }; + + if (newEvents.length > 0 || Object.keys(next).length > 0) { + this.checkAndTriggerAutomations(); + queueMicrotask(() => { + this.gameState = { ...this.gameState, pendingEvents: new Set() }; + }); + } + } + + private async checkAndTriggerAutomations() { + const automations = this.automationsService.state.automations.filter(a => a.enabled); + const state = this.gameState; + const prevState = this.prevState; + const currentGame = this.getCurrentGame().toLowerCase(); + + for (const automation of automations) { + const conditionResults: TEvaluatedCondition[] = []; + + for (const condition of automation.conditions as TCondition[]) { + const status = ConditionsManager.evaluate({ condition, state, prevState }); + const cachedStatus = this.automationPreviousConditionsMetCache.get(automation.id!); + if (cachedStatus === status) continue; + if (!condition.type.startsWith(currentGame)) continue; + this.automationPreviousConditionsMetCache.set(automation.id!, status); + conditionResults.push({ condition, status }); + } + + if (!conditionResults.length) continue; + + const conditionsNotMet = conditionResults.some(r => !r.status); + + for (const exportedAction of automation.actions) { + try { + const [action] = await Actions.fromExported([exportedAction as any], this.actionContext); + await Actions.process({ + conditionsMet: !conditionsNotMet, + conditions: conditionResults.map(r => r.condition), + action, + context: this.actionContext, + state, + prevState, + }); + } catch (error) { + console.warn('[AutomationsEngine] Action failed', { action: exportedAction.type, error }); + } + } + } + } +} diff --git a/app/services/stream-avatar/automations-service.ts b/app/services/stream-avatar/automations-service.ts new file mode 100644 index 000000000000..b8cd542bbf8c --- /dev/null +++ b/app/services/stream-avatar/automations-service.ts @@ -0,0 +1,111 @@ +import { InitAfter } from 'services/core'; +import { StatefulService, mutation } from 'services/core/stateful-service'; +import { Inject } from 'services/core/injector'; +import { AgentSocketService } from './agent-socket-service'; +import { UserService } from 'services/user'; +import { WindowsService } from 'services/windows'; +import { $t } from 'services/i18n'; +import Utils from 'services/utils'; +import type { TAutomationExport } from './engine/automations'; + +interface IAutomationsState { + automations: TAutomationExport[]; + loading: boolean; + loaded: boolean; +} + +@InitAfter('UserService') +export class AutomationsService extends StatefulService { + static initialState: IAutomationsState = { + automations: [], + loading: false, + loaded: false, + }; + + @Inject() private agentSocketService: AgentSocketService; + @Inject() private userService: UserService; + @Inject() private windowsService: WindowsService; + + init() { + console.log('[AutomationsService] init() called. isWorkerWindow:', Utils.isWorkerWindow()); + if (!Utils.isWorkerWindow()) return; + console.log('[AutomationsService] init() isLoggedIn:', this.userService.isLoggedIn); + if (this.userService.isLoggedIn) { + this.fetchAll(); + } + this.userService.userLogin.subscribe(() => { + console.log('[AutomationsService] userLogin fired, fetching'); + this.fetchAll(); + }); + } + + showAutomations() { + this.windowsService.showWindow({ + componentName: 'EditAutomations', + title: $t('Automations'), + size: { + width: 900, + height: 650, + }, + }); + } + + @mutation() + private SET_AUTOMATIONS(automations: TAutomationExport[]) { + this.state.automations = automations; + this.state.loaded = true; + this.state.loading = false; + } + + @mutation() + private SET_LOADING(loading: boolean) { + this.state.loading = loading; + } + + @mutation() + private ADD_AUTOMATION(automation: TAutomationExport) { + this.state.automations = [automation, ...this.state.automations]; + } + + @mutation() + private UPDATE_AUTOMATION(automation: TAutomationExport) { + this.state.automations = this.state.automations.map(a => + a.id === automation.id ? automation : a, + ); + } + + @mutation() + private REMOVE_AUTOMATION(id: number) { + this.state.automations = this.state.automations.filter(a => a.id !== id); + } + + async fetchAll(): Promise { + console.log('[AutomationsService] fetchAll() start'); + this.SET_LOADING(true); + try { + const automations = await this.agentSocketService.getAutomations(); + console.log('[AutomationsService] fetchAll() got', automations?.length, 'automations'); + this.SET_AUTOMATIONS(automations as TAutomationExport[]); + } catch (e) { + this.SET_LOADING(false); + console.error('[AutomationsService] fetchAll failed', e); + } + } + + async create(automation: Omit): Promise { + const created = await this.agentSocketService.createAutomation(automation); + this.ADD_AUTOMATION(created as TAutomationExport); + return created as TAutomationExport; + } + + async update(id: number, automation: Partial): Promise { + const updated = await this.agentSocketService.updateAutomation({ ...automation, id }); + this.UPDATE_AUTOMATION(updated as TAutomationExport); + return updated as TAutomationExport; + } + + async remove(id: number): Promise { + await this.agentSocketService.deleteAutomation(id); + this.REMOVE_AUTOMATION(id); + } +} diff --git a/app/services/stream-avatar/engine/actions.ts b/app/services/stream-avatar/engine/actions.ts new file mode 100644 index 000000000000..f62de788b506 --- /dev/null +++ b/app/services/stream-avatar/engine/actions.ts @@ -0,0 +1,300 @@ +import uuid from 'uuid/v4'; +import { Properties, PropertyInstance, PropertyMap } from './properties'; +import { Instructions } from './instructions'; +import type { TCondition } from './conditions'; +import type { GameState } from './game-state'; + +export type ResolveFromIdOrName = { name: string; id?: never } | { id: string; name?: never }; + +export type SceneRef = { id: string; name: string }; +export type SourceRef = { id: string; name: string }; + +export type ActionContext = { + resolveSceneId: (scene: ResolveFromIdOrName) => Promise; + resolveSourceId: (source: ResolveFromIdOrName) => Promise; + switchScene: (id: string) => void; + setSourceVisible: (id: string, visible: boolean) => void; + saveReplay: () => Promise; +}; + +export type ActionProps = { + scene?: { id: string }; + source?: { id: string }; + show_if_condition_false?: boolean; + hide_if_condition_false?: boolean; + duration?: number; + instruction?: string; + say?: string; + simulating?: boolean; +}; + +export type ExportedActionProps = { + scene?: { name: string }; + source?: { name: string }; + show_if_condition_false?: boolean; + hide_if_condition_false?: boolean; + duration?: number; + instruction?: string; + say?: string; +}; + +type ActionProcessPayload = { + conditionsMet: boolean; + conditions: TCondition[]; + context: ActionContext; + props?: ActionProps; + state: GameState; + prevState: GameState; +}; + +export type ActionDef = { + group: string; + name: string; + label: string; + properties?: PropertyMap; + process: (payload: ActionProcessPayload) => Promise; +}; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export const ActionRegistry = { + 'common.switch_to_scene': { + group: 'common', + name: 'switch_to_scene', + label: 'Switch to Scene', + properties: { + scene: new Properties.Scene({ label: 'Scene' }), + }, + process: async ({ conditionsMet, props, context }) => { + if (!conditionsMet || !props?.scene) return; + context.switchScene(props.scene.id); + }, + }, + + 'common.hide_source': { + group: 'common', + name: 'hide_source', + label: 'Hide Source', + properties: { + source: new Properties.Source({ label: 'Source' }), + show_if_condition_false: new Properties.Checkbox({ label: 'Show if condition is false' }), + }, + process: async ({ conditionsMet, props, context }) => { + if (!props?.source) return; + if (!conditionsMet) { + if (props.show_if_condition_false) { + context.setSourceVisible(props.source.id, true); + } + return; + } + context.setSourceVisible(props.source.id, false); + }, + }, + + 'common.show_source': { + group: 'common', + name: 'show_source', + label: 'Show Source', + properties: { + source: new Properties.Source({ label: 'Source' }), + hide_if_condition_false: new Properties.Checkbox({ label: 'Hide if condition is false' }), + }, + process: async ({ conditionsMet, props, context }) => { + if (!props?.source) return; + if (!conditionsMet) { + if (props.hide_if_condition_false) { + context.setSourceVisible(props.source.id, false); + } + return; + } + context.setSourceVisible(props.source.id, true); + }, + }, + + 'common.save_replay': { + group: 'common', + name: 'save_replay', + label: 'Save Replay', + process: async ({ conditionsMet, context }) => { + if (!conditionsMet) return; + await context.saveReplay(); + }, + }, + + 'common.wait_for_ms': { + group: 'common', + name: 'wait_for_ms', + label: 'Wait', + properties: { + duration: new Properties.Slider({ + label: 'Duration', + min: 500, + max: 60000, + default: 5000, + step: 500, + format: (ms: number) => `${(ms / 1000).toFixed(1)} ${ms === 1000 ? 'second' : 'seconds'}`, + }), + }, + process: async ({ conditionsMet, props }) => { + if (!conditionsMet || typeof props?.duration !== 'number') return; + await sleep(props.duration); + }, + }, + + 'co-host.comment': { + group: 'co-host', + name: 'comment', + label: 'Co-host Comment', + properties: {}, + process: async ({ conditionsMet, conditions }) => { + if (!conditionsMet) return; + for (const c of conditions) { + const instruction = Instructions[c.type as keyof typeof Instructions]; + console.log('[AutomationsEngine] co-host.comment', { condition: c.type, instruction }); + } + }, + }, + + 'co-host.instruction': { + group: 'co-host', + name: 'instruction', + label: 'Co-host Instruction', + properties: { + instruction: new Properties.Text({ label: 'Instruction' }), + }, + process: async ({ conditionsMet, props }) => { + if (!conditionsMet || !props?.instruction) return; + console.log('[AutomationsEngine] co-host.instruction', { instruction: props.instruction }); + }, + }, +} as const satisfies Record; + +export type ActionType = keyof typeof ActionRegistry; + +export type Action = { + id: string; + type: ActionType; + props?: ActionProps; +}; + +export type ExportedAction = { + type: ActionType; + props?: ExportedActionProps; +}; + +/** + * Reads the registry's property defaults for an action type into exported-props + * shape (e.g. wait_for_ms's 5000ms duration). Only primitive properties define + * defaults, and their exported form equals their internal form, so the config + * default can be used directly. + */ +export function defaultExportedProps(type: ActionType): ExportedActionProps | undefined { + const def = ActionRegistry[type]; + if (!('properties' in def) || !def.properties) return undefined; + + const props: Partial = {}; + for (const [key, property] of Object.entries(def.properties as Record)) { + const value = (property as any).config?.default; + if (value !== undefined) props[key as keyof ExportedActionProps] = value as never; + } + + return Object.keys(props).length ? (props as ExportedActionProps) : undefined; +} + +/** + * Ensures an action carries its property defaults so they are persisted rather + * than only shown as a UI placeholder. Existing props win over defaults. + */ +export function withActionDefaults(action: ExportedAction): ExportedAction { + const defaults = defaultExportedProps(action.type); + if (!defaults) return action; + return { ...action, props: { ...defaults, ...action.props } }; +} + +const valueFromExport = ( + property: PropertyInstance, + v: unknown, + context: ActionContext, +): Promise | unknown => + (property as any).valueFromExport(v, context); + +const valueToExport = ( + property: PropertyInstance, + v: unknown, + context: ActionContext, +): Promise | unknown => + (property as any).valueToExport(v, context); + +const importAction = async (exported: ExportedAction, context: ActionContext): Promise => { + const def = ActionRegistry[exported.type]; + if (!def) throw new Error(`Action ${exported.type} not found`); + + if (!('properties' in def) || !def.properties) { + return { id: uuid(), type: exported.type }; + } + + const props: Partial = {}; + for (const [key, property] of Object.entries(def.properties as Record)) { + const propValue = exported.props?.[key as keyof ExportedActionProps] as unknown; + props[key as keyof ActionProps] = (await valueFromExport(property, propValue, context)) as never; + } + + return { + id: uuid(), + type: exported.type, + props: Object.keys(props).length ? (props as ActionProps) : undefined, + }; +}; + +const exportAction = async (action: Action, context: ActionContext): Promise => { + const def = ActionRegistry[action.type]; + if (!def) throw new Error(`Action ${action.type} not found`); + + if (!('properties' in def) || !def.properties) { + return { type: action.type }; + } + + const props: Partial = {}; + for (const [key, property] of Object.entries(def.properties as Record)) { + const propValue = action.props?.[key as keyof ActionProps] as unknown; + props[key as keyof ExportedActionProps] = (await valueToExport(property, propValue, context)) as never; + } + + return { + type: action.type, + props: Object.keys(props).length ? (props as ExportedActionProps) : undefined, + }; +}; + +export class Actions { + static async fromExported(exported: ExportedAction[], context: ActionContext): Promise { + return Promise.all(exported.map(a => importAction(a, context))); + } + + static async toExported(actions: Action[], context: ActionContext): Promise { + return Promise.all(actions.map(a => exportAction(a, context))); + } + + static async process({ + action, + conditionsMet, + conditions, + context, + state, + prevState, + }: { + action: Action; + conditionsMet: boolean; + conditions: TCondition[]; + context: ActionContext; + state: GameState; + prevState: GameState; + }) { + const def = ActionRegistry[action.type]; + if (!def) throw new Error(`Action ${action.type} not found`); + + return def.process({ conditionsMet, conditions, context, props: action.props, state, prevState }); + } +} diff --git a/app/services/stream-avatar/engine/automations.ts b/app/services/stream-avatar/engine/automations.ts new file mode 100644 index 000000000000..ddc33c2ea7bf --- /dev/null +++ b/app/services/stream-avatar/engine/automations.ts @@ -0,0 +1,18 @@ +import type { Action, ExportedAction } from './actions'; +import type { TCondition } from './conditions'; + +export type TAutomation = { + id?: number; + description?: string; + conditions: TCondition[]; + actions: Action[]; + enabled: boolean; +}; + +export type TAutomationExport = { + id?: number; + description?: string; + conditions: TCondition[]; + actions: ExportedAction[]; + enabled: boolean; +}; diff --git a/app/services/stream-avatar/engine/conditions/apexlegends.ts b/app/services/stream-avatar/engine/conditions/apexlegends.ts new file mode 100644 index 000000000000..3fb92f776cf3 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/apexlegends.ts @@ -0,0 +1,114 @@ +import { ConditionDefinition } from '.'; + +export type ApexLegendsConditionPropsMap = { + //---------------------- + // Apex Legends + //---------------------- + + // Game Flow + 'apex_legends.game_start': undefined; + 'apex_legends.deploy': undefined; + 'apex_legends.storm_shrinking': undefined; + 'apex_legends.game_end': undefined; + + // Player + 'apex_legends.player_knocked': undefined; + 'apex_legends.player_revived': undefined; + 'apex_legends.player_eliminated': undefined; + + // Win / Lose + 'apex_legends.victory': undefined; + 'apex_legends.defeat': undefined; + + // Enemy + 'apex_legends.elimination': undefined; + 'apex_legends.knockout': undefined; +}; + +export type ApexLegendsConditionType = keyof ApexLegendsConditionPropsMap; +export type ApexLegendsConditionProps< + T extends ApexLegendsConditionType +> = ApexLegendsConditionPropsMap[T]; + +export const ApexLegendsConditions: { + [K in ApexLegendsConditionType]: ConditionDefinition; +} = { + 'apex_legends.game_start': { + group: 'apex_legends', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'apex_legends.deploy': { + group: 'apex_legends', + name: 'deploy', + label: 'Deployed', + evaluate: ({ state }) => state.pendingEvents.has('deploy'), + }, + + 'apex_legends.storm_shrinking': { + group: 'apex_legends', + name: 'storm_shrinking', + label: 'Ring Closing', + evaluate: ({ state }) => state.pendingEvents.has('storm_shrinking'), + }, + + 'apex_legends.game_end': { + group: 'apex_legends', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'apex_legends.player_knocked': { + group: 'apex_legends', + name: 'player_knocked', + label: 'Player Knocked', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + 'apex_legends.player_revived': { + group: 'apex_legends', + name: 'player_revived', + label: 'Player Revived', + evaluate: ({ state }) => state.pendingEvents.has('player_revived'), + }, + + 'apex_legends.player_eliminated': { + group: 'apex_legends', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'apex_legends.victory': { + group: 'apex_legends', + name: 'victory', + label: 'Victory (Champion)', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'apex_legends.defeat': { + group: 'apex_legends', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'apex_legends.elimination': { + group: 'apex_legends', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'apex_legends.knockout': { + group: 'apex_legends', + name: 'knockout', + label: 'Enemy Knocked', + evaluate: ({ state }) => state.pendingEvents.has('knockout'), + }, +} as const; + +export default ApexLegendsConditions; diff --git a/app/services/stream-avatar/engine/conditions/arcraiders.ts b/app/services/stream-avatar/engine/conditions/arcraiders.ts new file mode 100644 index 000000000000..10ed07e9ab4e --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/arcraiders.ts @@ -0,0 +1,100 @@ +import { ConditionDefinition } from '.'; + +export type ArcRaidersConditionPropsMap = { + //---------------------- + // Arc Raiders + //---------------------- + + // Game Flow + 'arc_raiders.game_start': undefined; + 'arc_raiders.game_end': undefined; + + // Win / Lose + 'arc_raiders.victory': undefined; + 'arc_raiders.defeat': undefined; + + // Player + 'arc_raiders.player_knocked': undefined; + 'arc_raiders.player_eliminated': undefined; + + // Enemy + 'arc_raiders.enemy_spotted': undefined; + 'arc_raiders.enemy_detected': undefined; + + // Moments + 'arc_raiders.interesting_moment': undefined; +}; + +export type ArcRaidersConditionType = keyof ArcRaidersConditionPropsMap; +export type ArcRaidersConditionProps< + T extends ArcRaidersConditionType +> = ArcRaidersConditionPropsMap[T]; + +export const ArcRaidersConditions: { + [K in ArcRaidersConditionType]: ConditionDefinition; +} = { + 'arc_raiders.game_start': { + group: 'arc_raiders', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'arc_raiders.game_end': { + group: 'arc_raiders', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'arc_raiders.victory': { + group: 'arc_raiders', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'arc_raiders.defeat': { + group: 'arc_raiders', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'arc_raiders.player_knocked': { + group: 'arc_raiders', + name: 'player_knocked', + label: 'Player Knocked', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + 'arc_raiders.player_eliminated': { + group: 'arc_raiders', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'arc_raiders.enemy_spotted': { + group: 'arc_raiders', + name: 'enemy_spotted', + label: 'Enemy Spotted', + evaluate: ({ state }) => state.pendingEvents.has('enemy_spotted'), + }, + + 'arc_raiders.enemy_detected': { + group: 'arc_raiders', + name: 'enemy_detected', + label: 'Enemy Detected', + evaluate: ({ state }) => state.pendingEvents.has('enemy_detected'), + }, + + 'arc_raiders.interesting_moment': { + group: 'arc_raiders', + name: 'interesting_moment', + label: 'Interesting Moment', + evaluate: ({ state }) => state.pendingEvents.has('interesting_moment'), + }, +} as const; + +export default ArcRaidersConditions; diff --git a/app/services/stream-avatar/engine/conditions/battlefield6.ts b/app/services/stream-avatar/engine/conditions/battlefield6.ts new file mode 100644 index 000000000000..69a492b25aa6 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/battlefield6.ts @@ -0,0 +1,72 @@ +import { ConditionDefinition } from '.'; + +export type Battlefield6ConditionPropsMap = { + //---------------------- + // Battlefield 6 + //---------------------- + + // Game Flow + 'battlefield_6.game_start': undefined; + 'battlefield_6.game_end': undefined; + + // Win / Lose + 'battlefield_6.victory': undefined; + 'battlefield_6.defeat': undefined; + + // Combat + 'battlefield_6.elimination': undefined; + 'battlefield_6.player_eliminated': undefined; +}; + +export type Battlefield6ConditionType = keyof Battlefield6ConditionPropsMap; +export type Battlefield6ConditionProps< + T extends Battlefield6ConditionType +> = Battlefield6ConditionPropsMap[T]; + +export const Battlefield6Conditions: { + [K in Battlefield6ConditionType]: ConditionDefinition; +} = { + 'battlefield_6.game_start': { + group: 'battlefield_6', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'battlefield_6.game_end': { + group: 'battlefield_6', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'battlefield_6.victory': { + group: 'battlefield_6', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'battlefield_6.defeat': { + group: 'battlefield_6', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'battlefield_6.elimination': { + group: 'battlefield_6', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'battlefield_6.player_eliminated': { + group: 'battlefield_6', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, +} as const; + +export default Battlefield6Conditions; diff --git a/app/services/stream-avatar/engine/conditions/blackops6.ts b/app/services/stream-avatar/engine/conditions/blackops6.ts new file mode 100644 index 000000000000..202c9134b0a7 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/blackops6.ts @@ -0,0 +1,54 @@ +import { ConditionDefinition } from '.'; + +export type BlackOps6ConditionPropsMap = { + //---------------------- + // Call of Duty: Black Ops 6 + //---------------------- + + // Player + 'black_ops_6.elimination': undefined; + 'black_ops_6.victory': undefined; + 'black_ops_6.defeat': undefined; + + // Game Flow + 'black_ops_6.spectating': undefined; +}; + +export type BlackOps6ConditionType = keyof BlackOps6ConditionPropsMap; +export type BlackOps6ConditionProps< + T extends BlackOps6ConditionType +> = BlackOps6ConditionPropsMap[T]; + +export const BlackOps6Conditions: { + [K in BlackOps6ConditionType]: ConditionDefinition; +} = { + 'black_ops_6.elimination': { + group: 'black_ops_6', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'black_ops_6.victory': { + group: 'black_ops_6', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'black_ops_6.defeat': { + group: 'black_ops_6', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'black_ops_6.spectating': { + group: 'black_ops_6', + name: 'spectating', + label: 'Spectating', + evaluate: ({ state }) => state.pendingEvents.has('spectating'), + }, +} as const; + +export default BlackOps6Conditions; diff --git a/app/services/stream-avatar/engine/conditions/counterstrike2.ts b/app/services/stream-avatar/engine/conditions/counterstrike2.ts new file mode 100644 index 000000000000..092bda57ad09 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/counterstrike2.ts @@ -0,0 +1,146 @@ +import { Properties } from '../properties'; +import { ConditionDefinition } from '.'; + +export type CounterStrike2ConditionPropsMap = { + //---------------------- + // Counter-Strike 2 + //---------------------- + + // Game Flow + 'counter_strike_2.round_started': undefined; + 'counter_strike_2.first_half': undefined; + 'counter_strike_2.second_half': undefined; + 'counter_strike_2.round_won': undefined; + 'counter_strike_2.round_lost': undefined; + 'counter_strike_2.game_ended': undefined; + + // Health Shield + 'counter_strike_2.low_health': undefined; + + // Player + 'counter_strike_2.victory': undefined; + 'counter_strike_2.player_eliminated': undefined; + 'counter_strike_2.defeat': undefined; + + // Enemy + 'counter_strike_2.elimination': undefined; + 'counter_strike_2.elimination_count': { + elimination_count?: [number, number]; + }; +}; + +export type CounterStrike2ConditionType = keyof CounterStrike2ConditionPropsMap; +export type CounterStrike2ConditionProps< + T extends CounterStrike2ConditionType +> = CounterStrike2ConditionPropsMap[T]; + +export const CounterStrike2Conditions: { + [K in CounterStrike2ConditionType]: ConditionDefinition; +} = { + // Game Flow + 'counter_strike_2.round_started': { + group: 'counter_strike_2', + name: 'round_started', + label: 'Round Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + // Health / Shield Conditions + 'counter_strike_2.low_health': { + group: 'counter_strike_2', + name: 'low_health', + label: 'Low Health', + evaluate: ({ state }) => { + const { health = 0 } = state; + return health > 0 && health < 50; + }, + }, + + // Player + 'counter_strike_2.victory': { + group: 'counter_strike_2', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'counter_strike_2.defeat': { + group: 'counter_strike_2', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'counter_strike_2.player_eliminated': { + group: 'counter_strike_2', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + // Enemy + 'counter_strike_2.elimination': { + group: 'counter_strike_2', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'counter_strike_2.elimination_count': { + group: 'counter_strike_2', + name: 'elimination_count', + label: 'Enemy Elimination Count', + properties: { + elimination_count: new Properties.SliderRange({ + label: '# of Eliminations', + min: 0, + max: 50, + default: [5, 5], + step: 1, + }), + }, + evaluate: ({ state, prevState, props }) => { + const [min, max] = props?.elimination_count ?? [5, 5]; + const { eliminations = 0 } = state; + const { eliminations: prevEliminations = 0 } = prevState; + return eliminations >= min && prevEliminations <= max; + }, + }, + + 'counter_strike_2.first_half': { + group: 'counter_strike_2', + name: 'first_half', + label: 'First Half', + evaluate: ({ state }) => state.pendingEvents.has('first_half'), + }, + + 'counter_strike_2.second_half': { + group: 'counter_strike_2', + name: 'second_half', + label: 'Second Half', + evaluate: ({ state }) => state.pendingEvents.has('second_half'), + }, + + 'counter_strike_2.round_won': { + group: 'counter_strike_2', + name: 'round_won', + label: 'Round Won', + evaluate: ({ state }) => state.pendingEvents.has('round_won'), + }, + + 'counter_strike_2.round_lost': { + group: 'counter_strike_2', + name: 'round_lost', + label: 'Round Lost', + evaluate: ({ state }) => state.pendingEvents.has('round_lost'), + }, + + 'counter_strike_2.game_ended': { + group: 'counter_strike_2', + name: 'game_ended', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, +} as const; + +export default CounterStrike2Conditions; diff --git a/app/services/stream-avatar/engine/conditions/deadbydaylight.ts b/app/services/stream-avatar/engine/conditions/deadbydaylight.ts new file mode 100644 index 000000000000..b6763b6163cc --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/deadbydaylight.ts @@ -0,0 +1,80 @@ +import { ConditionDefinition } from '.'; + +export type DeadByDaylightConditionPropsMap = { + //---------------------- + // Dead by Daylight + //---------------------- + + // Game Flow + 'dead_by_daylight.game_start': undefined; + 'dead_by_daylight.game_end': undefined; + + // Win / Lose + 'dead_by_daylight.victory': undefined; + 'dead_by_daylight.player_eliminated': undefined; + + // Combat / Events + 'dead_by_daylight.elimination': undefined; + 'dead_by_daylight.hooked_survivor': undefined; + 'dead_by_daylight.escaped': undefined; +}; + +export type DeadByDaylightConditionType = keyof DeadByDaylightConditionPropsMap; +export type DeadByDaylightConditionProps< + T extends DeadByDaylightConditionType +> = DeadByDaylightConditionPropsMap[T]; + +export const DeadByDaylightConditions: { + [K in DeadByDaylightConditionType]: ConditionDefinition; +} = { + 'dead_by_daylight.game_start': { + group: 'dead_by_daylight', + name: 'game_start', + label: 'Match Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'dead_by_daylight.game_end': { + group: 'dead_by_daylight', + name: 'game_end', + label: 'Match Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'dead_by_daylight.victory': { + group: 'dead_by_daylight', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'dead_by_daylight.player_eliminated': { + group: 'dead_by_daylight', + name: 'player_eliminated', + label: 'Player Sacrificed / Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'dead_by_daylight.elimination': { + group: 'dead_by_daylight', + name: 'elimination', + label: 'Survivor Sacrificed (Killer)', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'dead_by_daylight.hooked_survivor': { + group: 'dead_by_daylight', + name: 'hooked_survivor', + label: 'Survivor Hooked', + evaluate: ({ state }) => state.pendingEvents.has('hooked_survivor'), + }, + + 'dead_by_daylight.escaped': { + group: 'dead_by_daylight', + name: 'escaped', + label: 'Survivor Escaped', + evaluate: ({ state }) => state.pendingEvents.has('escaped'), + }, +} as const; + +export default DeadByDaylightConditions; diff --git a/app/services/stream-avatar/engine/conditions/deadlock.ts b/app/services/stream-avatar/engine/conditions/deadlock.ts new file mode 100644 index 000000000000..4b8ae977c8ed --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/deadlock.ts @@ -0,0 +1,70 @@ +import { ConditionDefinition } from '.'; + +export type DeadlockConditionPropsMap = { + //---------------------- + // Deadlock + //---------------------- + + // Game Flow + 'deadlock.game_start': undefined; + 'deadlock.game_end': undefined; + + // Win / Lose + 'deadlock.victory': undefined; + 'deadlock.defeat': undefined; + + // Combat + 'deadlock.elimination': undefined; + 'deadlock.player_eliminated': undefined; +}; + +export type DeadlockConditionType = keyof DeadlockConditionPropsMap; +export type DeadlockConditionProps = DeadlockConditionPropsMap[T]; + +export const DeadlockConditions: { + [K in DeadlockConditionType]: ConditionDefinition; +} = { + 'deadlock.game_start': { + group: 'deadlock', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'deadlock.game_end': { + group: 'deadlock', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'deadlock.victory': { + group: 'deadlock', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'deadlock.defeat': { + group: 'deadlock', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'deadlock.elimination': { + group: 'deadlock', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'deadlock.player_eliminated': { + group: 'deadlock', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, +} as const; + +export default DeadlockConditions; diff --git a/app/services/stream-avatar/engine/conditions/dota2.ts b/app/services/stream-avatar/engine/conditions/dota2.ts new file mode 100644 index 000000000000..9b0ae93e9dae --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/dota2.ts @@ -0,0 +1,88 @@ +import { ConditionDefinition } from '.'; + +export type Dota2ConditionPropsMap = { + //---------------------- + // Dota 2 + //---------------------- + + // Game Flow + 'dota_2.game_start': undefined; + 'dota_2.game_end': undefined; + + // Win / Lose + 'dota_2.victory': undefined; + 'dota_2.defeat': undefined; + + // Combat + 'dota_2.elimination': undefined; + 'dota_2.player_eliminated': undefined; + + // Objectives + 'dota_2.tower_destroyed': undefined; + 'dota_2.glyph_used': undefined; +}; + +export type Dota2ConditionType = keyof Dota2ConditionPropsMap; +export type Dota2ConditionProps = Dota2ConditionPropsMap[T]; + +export const Dota2Conditions: { + [K in Dota2ConditionType]: ConditionDefinition; +} = { + 'dota_2.game_start': { + group: 'dota_2', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'dota_2.game_end': { + group: 'dota_2', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'dota_2.victory': { + group: 'dota_2', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'dota_2.defeat': { + group: 'dota_2', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'dota_2.elimination': { + group: 'dota_2', + name: 'elimination', + label: 'Hero Kill', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'dota_2.player_eliminated': { + group: 'dota_2', + name: 'player_eliminated', + label: 'Player Died', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'dota_2.tower_destroyed': { + group: 'dota_2', + name: 'tower_destroyed', + label: 'Tower Destroyed', + evaluate: ({ state }) => state.pendingEvents.has('tower_destroyed'), + }, + + 'dota_2.glyph_used': { + group: 'dota_2', + name: 'glyph_used', + label: 'Glyph of Fortification Used', + evaluate: ({ state }) => state.pendingEvents.has('glyph_used'), + }, +} as const; + +export default Dota2Conditions; diff --git a/app/services/stream-avatar/engine/conditions/easportsfc26.ts b/app/services/stream-avatar/engine/conditions/easportsfc26.ts new file mode 100644 index 000000000000..66ddb71860c8 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/easportsfc26.ts @@ -0,0 +1,70 @@ +import { ConditionDefinition } from '.'; + +export type EaSportsFc26ConditionPropsMap = { + //---------------------- + // EA Sports FC 26 + //---------------------- + + // Game Flow + 'ea_sports_fc_26.game_start': undefined; + 'ea_sports_fc_26.game_end': undefined; + + // Match Events + 'ea_sports_fc_26.goal': undefined; + 'ea_sports_fc_26.set_piece': undefined; + 'ea_sports_fc_26.halftime': undefined; + 'ea_sports_fc_26.fulltime': undefined; +}; + +export type EaSportsFc26ConditionType = keyof EaSportsFc26ConditionPropsMap; +export type EaSportsFc26ConditionProps< + T extends EaSportsFc26ConditionType +> = EaSportsFc26ConditionPropsMap[T]; + +export const EaSportsFc26Conditions: { + [K in EaSportsFc26ConditionType]: ConditionDefinition; +} = { + 'ea_sports_fc_26.game_start': { + group: 'ea_sports_fc_26', + name: 'game_start', + label: 'Match Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'ea_sports_fc_26.game_end': { + group: 'ea_sports_fc_26', + name: 'game_end', + label: 'Match Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'ea_sports_fc_26.goal': { + group: 'ea_sports_fc_26', + name: 'goal', + label: 'Goal Scored', + evaluate: ({ state }) => state.pendingEvents.has('goal'), + }, + + 'ea_sports_fc_26.set_piece': { + group: 'ea_sports_fc_26', + name: 'set_piece', + label: 'Set Piece', + evaluate: ({ state }) => state.pendingEvents.has('set_piece'), + }, + + 'ea_sports_fc_26.halftime': { + group: 'ea_sports_fc_26', + name: 'halftime', + label: 'Half Time', + evaluate: ({ state }) => state.pendingEvents.has('halftime'), + }, + + 'ea_sports_fc_26.fulltime': { + group: 'ea_sports_fc_26', + name: 'fulltime', + label: 'Full Time', + evaluate: ({ state }) => state.pendingEvents.has('fulltime'), + }, +} as const; + +export default EaSportsFc26Conditions; diff --git a/app/services/stream-avatar/engine/conditions/enshrouded.ts b/app/services/stream-avatar/engine/conditions/enshrouded.ts new file mode 100644 index 000000000000..c32cd58d85d0 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/enshrouded.ts @@ -0,0 +1,56 @@ +import { ConditionDefinition } from '.'; + +export type EnshroudedConditionPropsMap = { + //---------------------- + // Enshrouded + //---------------------- + + // Player Events + 'enshrouded.player_eliminated': undefined; + 'enshrouded.level_up': undefined; + + // Exploration + 'enshrouded.soul_discovered': undefined; + + // Quests + 'enshrouded.quest_update': undefined; +}; + +export type EnshroudedConditionType = keyof EnshroudedConditionPropsMap; +export type EnshroudedConditionProps< + T extends EnshroudedConditionType +> = EnshroudedConditionPropsMap[T]; + +export const EnshroudedConditions: { + [K in EnshroudedConditionType]: ConditionDefinition; +} = { + 'enshrouded.player_eliminated': { + group: 'enshrouded', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'enshrouded.level_up': { + group: 'enshrouded', + name: 'level_up', + label: 'Level Up', + evaluate: ({ state }) => state.pendingEvents.has('level_up'), + }, + + 'enshrouded.soul_discovered': { + group: 'enshrouded', + name: 'soul_discovered', + label: 'Soul Discovered', + evaluate: ({ state }) => state.pendingEvents.has('soul_discovered'), + }, + + 'enshrouded.quest_update': { + group: 'enshrouded', + name: 'quest_update', + label: 'Quest Updated', + evaluate: ({ state }) => state.pendingEvents.has('quest_update'), + }, +} as const; + +export default EnshroudedConditions; diff --git a/app/services/stream-avatar/engine/conditions/f125.ts b/app/services/stream-avatar/engine/conditions/f125.ts new file mode 100644 index 000000000000..c8d5f78667ce --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/f125.ts @@ -0,0 +1,62 @@ +import { ConditionDefinition } from '.'; + +export type F125ConditionPropsMap = { + //---------------------- + // F1 25 + //---------------------- + + // Game Flow + 'f1_25.game_start': undefined; + 'f1_25.game_end': undefined; + + // Win + 'f1_25.victory': undefined; + + // Race Events + 'f1_25.position_change': undefined; + 'f1_25.lap_change': undefined; +}; + +export type F125ConditionType = keyof F125ConditionPropsMap; +export type F125ConditionProps = F125ConditionPropsMap[T]; + +export const F125Conditions: { + [K in F125ConditionType]: ConditionDefinition; +} = { + 'f1_25.game_start': { + group: 'f1_25', + name: 'game_start', + label: 'Race Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'f1_25.game_end': { + group: 'f1_25', + name: 'game_end', + label: 'Race Ended (Chequered Flag)', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'f1_25.victory': { + group: 'f1_25', + name: 'victory', + label: 'Race Win (P1)', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'f1_25.position_change': { + group: 'f1_25', + name: 'position_change', + label: 'Race Position Changed', + evaluate: ({ state }) => state.pendingEvents.has('position_change'), + }, + + 'f1_25.lap_change': { + group: 'f1_25', + name: 'lap_change', + label: 'New Lap Started', + evaluate: ({ state }) => state.pendingEvents.has('lap_change'), + }, +} as const; + +export default F125Conditions; diff --git a/app/services/stream-avatar/engine/conditions/fortnite.ts b/app/services/stream-avatar/engine/conditions/fortnite.ts new file mode 100644 index 000000000000..156f193aeb19 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/fortnite.ts @@ -0,0 +1,184 @@ +import { Properties } from '../properties'; +import { ConditionDefinition } from '.'; + +export type FortniteConditionPropsMap = { + //---------------------- + // Fortnite + //---------------------- + + // Game Flow + 'fortnite.game_started': undefined; + 'fortnite.deployed': undefined; + 'fortnite.storm_closing': undefined; + 'fortnite.game_ended': undefined; + + // Health / Shield + 'fortnite.low_health': undefined; + 'fortnite.has_shield': undefined; + 'fortnite.no_shield': undefined; + + // Win / Lose + 'fortnite.victory_royale': undefined; + 'fortnite.player_eliminated': undefined; + 'fortnite.player_knocked': undefined; + 'fortnite.defeat': undefined; + + // Enemy + 'fortnite.elimination': undefined; + 'fortnite.knocked': undefined; + 'fortnite.elimination_count': { elimination_count?: [number, number] }; + 'fortnite.players_remaining': { players_remaining?: [number, number] }; +}; + +export type FortniteConditionType = keyof FortniteConditionPropsMap; +export type FortniteConditionProps = FortniteConditionPropsMap[T]; + +export const FortniteConditions: { + [K in FortniteConditionType]: ConditionDefinition; +} = { + 'fortnite.game_started': { + group: 'fortnite', + name: 'game_started', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'fortnite.deployed': { + group: 'fortnite', + name: 'deployed', + label: 'Deployed', + evaluate: ({ state }) => state.pendingEvents.has('deploy'), + }, + + 'fortnite.game_ended': { + group: 'fortnite', + name: 'game_ended', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + // Health / Shield Conditions + 'fortnite.low_health': { + group: 'fortnite', + name: 'low_health', + label: 'Low Health', + evaluate: ({ state }) => { + const { health = 0 } = state; + return health > 0 && health < 50; + }, + }, + + 'fortnite.has_shield': { + group: 'fortnite', + name: 'has_shield', + label: 'Has Shield', + evaluate: ({ state }) => { + const { shield = 0 } = state; + return shield > 0; + }, + }, + + 'fortnite.no_shield': { + group: 'fortnite', + name: 'no_shield', + label: 'No Shield', + evaluate: ({ state }) => { + const { shield = 0 } = state; + return shield === 0; + }, + }, + + // Win / Lose Conditions + 'fortnite.victory_royale': { + group: 'fortnite', + name: 'victory_royale', + label: 'Victory Royale', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'fortnite.defeat': { + group: 'fortnite', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'fortnite.player_eliminated': { + group: 'fortnite', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'fortnite.player_knocked': { + group: 'fortnite', + name: 'player_knocked', + label: 'Player Knocked', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + 'fortnite.storm_closing': { + group: 'fortnite', + name: 'storm_closing', + label: 'Storm Closing', + evaluate: ({ state }) => state.pendingEvents.has('storm_shrinking'), + }, + + 'fortnite.elimination': { + group: 'fortnite', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'fortnite.knocked': { + group: 'fortnite', + name: 'knocked', + label: 'Enemy Knocked', + evaluate: ({ state }) => state.pendingEvents.has('knockout'), + }, + + 'fortnite.elimination_count': { + group: 'fortnite', + name: 'elimination_count', + label: 'Enemy Elimination Count', + properties: { + elimination_count: new Properties.SliderRange({ + label: '# of Eliminations', + min: 0, + max: 50, + default: [5, 5], + step: 1, + }), + }, + evaluate: ({ state, prevState, props }) => { + const [min, max] = props?.elimination_count ?? [5, 5]; + const { eliminations = 0 } = state; + const { eliminations: prevEliminations = 0 } = prevState; + return eliminations >= min && prevEliminations <= max; + }, + }, + + 'fortnite.players_remaining': { + group: 'fortnite', + name: 'players_remaining', + label: 'Players Remaining (coming soon)', + disabled: true, + properties: { + players_remaining: new Properties.SliderRange({ + label: '# of Players Remaining', + min: 1, + max: 100, + default: [1, 1], + step: 1, + }), + }, + evaluate: ({ state, props }) => { + const [min, max] = props?.players_remaining ?? [1, 1]; + const { playersRemaining = 0 } = state; + return playersRemaining >= min && playersRemaining <= max; + }, + }, +} as const; + +export default FortniteConditions; diff --git a/app/services/stream-avatar/engine/conditions/forzahorizon6.ts b/app/services/stream-avatar/engine/conditions/forzahorizon6.ts new file mode 100644 index 000000000000..f96be39a956f --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/forzahorizon6.ts @@ -0,0 +1,80 @@ +import { ConditionDefinition } from '.'; + +export type ForzaHorizon6ConditionPropsMap = { + //---------------------- + // Forza Horizon 6 + //---------------------- + + // Game Flow + 'forza_horizon_6.game_start': undefined; + 'forza_horizon_6.game_end': undefined; + + // Race Events + 'forza_horizon_6.position_change': undefined; + 'forza_horizon_6.lap_change': undefined; + + // Skill Events + 'forza_horizon_6.great_drift': undefined; + 'forza_horizon_6.great_air': undefined; + 'forza_horizon_6.great_skill_chain': undefined; +}; + +export type ForzaHorizon6ConditionType = keyof ForzaHorizon6ConditionPropsMap; +export type ForzaHorizon6ConditionProps< + T extends ForzaHorizon6ConditionType +> = ForzaHorizon6ConditionPropsMap[T]; + +export const ForzaHorizon6Conditions: { + [K in ForzaHorizon6ConditionType]: ConditionDefinition; +} = { + 'forza_horizon_6.game_start': { + group: 'forza_horizon_6', + name: 'game_start', + label: 'Race Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'forza_horizon_6.game_end': { + group: 'forza_horizon_6', + name: 'game_end', + label: 'Race Finished', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'forza_horizon_6.position_change': { + group: 'forza_horizon_6', + name: 'position_change', + label: 'Race Position Changed', + evaluate: ({ state }) => state.pendingEvents.has('position_change'), + }, + + 'forza_horizon_6.lap_change': { + group: 'forza_horizon_6', + name: 'lap_change', + label: 'New Lap Started', + evaluate: ({ state }) => state.pendingEvents.has('lap_change'), + }, + + 'forza_horizon_6.great_drift': { + group: 'forza_horizon_6', + name: 'great_drift', + label: 'Great Drift', + evaluate: ({ state }) => state.pendingEvents.has('great_drift'), + }, + + 'forza_horizon_6.great_air': { + group: 'forza_horizon_6', + name: 'great_air', + label: 'Great Air', + evaluate: ({ state }) => state.pendingEvents.has('great_air'), + }, + + 'forza_horizon_6.great_skill_chain': { + group: 'forza_horizon_6', + name: 'great_skill_chain', + label: 'Great Skill Chain', + evaluate: ({ state }) => state.pendingEvents.has('great_skill_chain'), + }, +} as const; + +export default ForzaHorizon6Conditions; diff --git a/app/services/stream-avatar/engine/conditions/index.ts b/app/services/stream-avatar/engine/conditions/index.ts new file mode 100644 index 000000000000..5bc9a5dfe0a5 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/index.ts @@ -0,0 +1,187 @@ +import type { PropertyInstance } from '../properties'; +import type { GameState } from '../game-state'; +import FortniteConditions, { FortniteConditionPropsMap } from './fortnite'; +import PubgConditions, { PubgConditionPropsMap } from './pubg'; +import ValorantConditions, { ValorantConditionPropsMap } from './valorant'; +import CounterStrike2Conditions, { CounterStrike2ConditionPropsMap } from './counterstrike2'; +import WarzoneConditions, { WarzoneConditionPropsMap } from './warzone'; +import ArcRaidersConditions, { ArcRaidersConditionPropsMap } from './arcraiders'; +import BlackOps6Conditions, { BlackOps6ConditionPropsMap } from './blackops6'; +import RocketLeagueConditions, { RocketLeagueConditionPropsMap } from './rocketleague'; +import MinecraftConditions, { MinecraftConditionPropsMap } from './minecraft'; +import ApexLegendsConditions, { ApexLegendsConditionPropsMap } from './apexlegends'; +import Battlefield6Conditions, { Battlefield6ConditionPropsMap } from './battlefield6'; +import DeadByDaylightConditions, { DeadByDaylightConditionPropsMap } from './deadbydaylight'; +import DeadlockConditions, { DeadlockConditionPropsMap } from './deadlock'; +import Dota2Conditions, { Dota2ConditionPropsMap } from './dota2'; +import LeagueOfLegendsConditions, { LeagueOfLegendsConditionPropsMap } from './leagueoflegends'; +import MarvelRivalsConditions, { MarvelRivalsConditionPropsMap } from './marvelrivals'; +import Overwatch2Conditions, { Overwatch2ConditionPropsMap } from './overwatch2'; +import RainbowSixSiegeConditions, { RainbowSixSiegeConditionPropsMap } from './rainbowsixsiege'; +import WarThunderConditions, { WarThunderConditionPropsMap } from './warthunder'; +import MarathonConditions, { MarathonConditionPropsMap } from './marathon'; +import F125Conditions, { F125ConditionPropsMap } from './f125'; +import EaSportsFc26Conditions, { EaSportsFc26ConditionPropsMap } from './easportsfc26'; +import Nba2k26Conditions, { Nba2k26ConditionPropsMap } from './nba2k26'; +import ForzaHorizon6Conditions, { ForzaHorizon6ConditionPropsMap } from './forzahorizon6'; +import EnshroudedConditions, { EnshroudedConditionPropsMap } from './enshrouded'; + +export type TEvaluatedCondition = { + condition: T; + status: boolean; +}; + +export type ConditionPropsMap = FortniteConditionPropsMap & + PubgConditionPropsMap & + ValorantConditionPropsMap & + CounterStrike2ConditionPropsMap & + WarzoneConditionPropsMap & + ArcRaidersConditionPropsMap & + BlackOps6ConditionPropsMap & + RocketLeagueConditionPropsMap & + MinecraftConditionPropsMap & + ApexLegendsConditionPropsMap & + Battlefield6ConditionPropsMap & + DeadByDaylightConditionPropsMap & + DeadlockConditionPropsMap & + Dota2ConditionPropsMap & + LeagueOfLegendsConditionPropsMap & + MarvelRivalsConditionPropsMap & + Overwatch2ConditionPropsMap & + RainbowSixSiegeConditionPropsMap & + WarThunderConditionPropsMap & + MarathonConditionPropsMap & + F125ConditionPropsMap & + EaSportsFc26ConditionPropsMap & + Nba2k26ConditionPropsMap & + ForzaHorizon6ConditionPropsMap & + EnshroudedConditionPropsMap; + +export type ConditionType = + | keyof FortniteConditionPropsMap + | keyof PubgConditionPropsMap + | keyof ValorantConditionPropsMap + | keyof CounterStrike2ConditionPropsMap + | keyof WarzoneConditionPropsMap + | keyof ArcRaidersConditionPropsMap + | keyof BlackOps6ConditionPropsMap + | keyof RocketLeagueConditionPropsMap + | keyof MinecraftConditionPropsMap + | keyof ApexLegendsConditionPropsMap + | keyof Battlefield6ConditionPropsMap + | keyof DeadByDaylightConditionPropsMap + | keyof DeadlockConditionPropsMap + | keyof Dota2ConditionPropsMap + | keyof LeagueOfLegendsConditionPropsMap + | keyof MarvelRivalsConditionPropsMap + | keyof Overwatch2ConditionPropsMap + | keyof RainbowSixSiegeConditionPropsMap + | keyof WarThunderConditionPropsMap + | keyof MarathonConditionPropsMap + | keyof F125ConditionPropsMap + | keyof EaSportsFc26ConditionPropsMap + | keyof Nba2k26ConditionPropsMap + | keyof ForzaHorizon6ConditionPropsMap + | keyof EnshroudedConditionPropsMap; + +export type ConditionProps = ConditionPropsMap[T]; + +export type ConditionDefinition = { + group: string; + name: string; + label: string; + disabled?: boolean; + properties?: Record; + evaluate: (args: { + state: GameState; + prevState: GameState; + props: ConditionPropsMap[K]; + }) => boolean; +}; + +const perGameConditions = { + ...FortniteConditions, + ...PubgConditions, + ...ValorantConditions, + ...CounterStrike2Conditions, + ...WarzoneConditions, + ...ArcRaidersConditions, + ...BlackOps6Conditions, + ...RocketLeagueConditions, + ...MinecraftConditions, + ...ApexLegendsConditions, + ...Battlefield6Conditions, + ...DeadByDaylightConditions, + ...DeadlockConditions, + ...Dota2Conditions, + ...LeagueOfLegendsConditions, + ...MarvelRivalsConditions, + ...Overwatch2Conditions, + ...RainbowSixSiegeConditions, + ...WarThunderConditions, + ...MarathonConditions, + ...F125Conditions, + ...EaSportsFc26Conditions, + ...Nba2k26Conditions, + ...ForzaHorizon6Conditions, + ...EnshroudedConditions, +} as const; + +export const Conditions: { [K in ConditionType]: ConditionDefinition } = perGameConditions; + +export const GAME_NAMES: Record = { + fortnite: 'Fortnite', + pubg: 'PUBG: Battlegrounds', + valorant: 'Valorant', + counter_strike_2: 'Counter-Strike 2', + black_ops_6: 'Call of Duty: Black Ops 6', + warzone: 'Call of Duty: Warzone', + rocket_league: 'Rocket League', + arc_raiders: 'Arc Raiders', + minecraft: 'Minecraft', + apex_legends: 'Apex Legends', + battlefield_6: 'Battlefield 6', + dead_by_daylight: 'Dead by Daylight', + deadlock: 'Deadlock', + dota_2: 'Dota 2', + league_of_legends: 'League of Legends', + marvel_rivals: 'Marvel Rivals', + overwatch_2: 'Overwatch 2', + rainbow_six_siege: 'Rainbow Six Siege', + war_thunder: 'War Thunder', + marathon: 'Marathon', + f1_25: 'F1 25', + ea_sports_fc_26: 'EA Sports FC 26', + nba_2k26: 'NBA 2K26', + forza_horizon_6: 'Forza Horizon 6', + enshrouded: 'Enshrouded', +}; + +export type TCondition = { + type: T; + props?: ConditionProps; +}; + +export class ConditionsManager { + static evaluate({ + condition, + state, + prevState, + }: { + condition: TCondition; + state: GameState; + prevState: GameState; + }) { + const def = Conditions[condition.type]; + if (!def) { + throw new Error(`Condition type "${condition.type}" not found`); + } + + const evaluateFn = (def as ConditionDefinition).evaluate; + return evaluateFn({ + state, + prevState, + props: condition.props as ConditionProps, + }); + } +} diff --git a/app/services/stream-avatar/engine/conditions/leagueoflegends.ts b/app/services/stream-avatar/engine/conditions/leagueoflegends.ts new file mode 100644 index 000000000000..1bb041b0e752 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/leagueoflegends.ts @@ -0,0 +1,106 @@ +import { ConditionDefinition } from '.'; + +export type LeagueOfLegendsConditionPropsMap = { + //---------------------- + // League of Legends + //---------------------- + + // Game Flow + 'league_of_legends.game_start': undefined; + 'league_of_legends.game_end': undefined; + + // Win / Lose + 'league_of_legends.victory': undefined; + 'league_of_legends.defeat': undefined; + + // Combat + 'league_of_legends.elimination': undefined; + 'league_of_legends.player_eliminated': undefined; + + // Objectives + 'league_of_legends.objective_ally': undefined; + 'league_of_legends.objective_enemy': undefined; + 'league_of_legends.enemy_turret_destroyed': undefined; + 'league_of_legends.ally_turret_destroyed': undefined; +}; + +export type LeagueOfLegendsConditionType = keyof LeagueOfLegendsConditionPropsMap; +export type LeagueOfLegendsConditionProps< + T extends LeagueOfLegendsConditionType +> = LeagueOfLegendsConditionPropsMap[T]; + +export const LeagueOfLegendsConditions: { + [K in LeagueOfLegendsConditionType]: ConditionDefinition; +} = { + 'league_of_legends.game_start': { + group: 'league_of_legends', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'league_of_legends.game_end': { + group: 'league_of_legends', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'league_of_legends.victory': { + group: 'league_of_legends', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'league_of_legends.defeat': { + group: 'league_of_legends', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'league_of_legends.elimination': { + group: 'league_of_legends', + name: 'elimination', + label: 'Champion Kill', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'league_of_legends.player_eliminated': { + group: 'league_of_legends', + name: 'player_eliminated', + label: 'Player Died', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'league_of_legends.objective_ally': { + group: 'league_of_legends', + name: 'objective_ally', + label: 'Ally Team Secured Objective', + evaluate: ({ state }) => state.pendingEvents.has('objective_ally'), + }, + + 'league_of_legends.objective_enemy': { + group: 'league_of_legends', + name: 'objective_enemy', + label: 'Enemy Team Secured Objective', + evaluate: ({ state }) => state.pendingEvents.has('objective_enemy'), + }, + + 'league_of_legends.enemy_turret_destroyed': { + group: 'league_of_legends', + name: 'enemy_turret_destroyed', + label: 'Enemy Turret Destroyed', + evaluate: ({ state }) => state.pendingEvents.has('enemy_turret_destroyed'), + }, + + 'league_of_legends.ally_turret_destroyed': { + group: 'league_of_legends', + name: 'ally_turret_destroyed', + label: 'Ally Turret Destroyed', + evaluate: ({ state }) => state.pendingEvents.has('ally_turret_destroyed'), + }, +} as const; + +export default LeagueOfLegendsConditions; diff --git a/app/services/stream-avatar/engine/conditions/marathon.ts b/app/services/stream-avatar/engine/conditions/marathon.ts new file mode 100644 index 000000000000..c568102fd761 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/marathon.ts @@ -0,0 +1,88 @@ +import { ConditionDefinition } from '.'; + +export type MarathonConditionPropsMap = { + //---------------------- + // Marathon + //---------------------- + + // Game Flow + 'marathon.game_start': undefined; + 'marathon.game_end': undefined; + + // Win / Lose + 'marathon.victory': undefined; + 'marathon.defeat': undefined; + + // Player + 'marathon.player_knocked': undefined; + 'marathon.player_eliminated': undefined; + + // Enemy + 'marathon.elimination': undefined; + 'marathon.knockout': undefined; +}; + +export type MarathonConditionType = keyof MarathonConditionPropsMap; +export type MarathonConditionProps = MarathonConditionPropsMap[T]; + +export const MarathonConditions: { + [K in MarathonConditionType]: ConditionDefinition; +} = { + 'marathon.game_start': { + group: 'marathon', + name: 'game_start', + label: 'Match Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'marathon.game_end': { + group: 'marathon', + name: 'game_end', + label: 'Match Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'marathon.victory': { + group: 'marathon', + name: 'victory', + label: 'Exfiltrated (Victory)', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'marathon.defeat': { + group: 'marathon', + name: 'defeat', + label: 'Eliminated (Defeat)', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'marathon.player_knocked': { + group: 'marathon', + name: 'player_knocked', + label: 'Player Knocked', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + 'marathon.player_eliminated': { + group: 'marathon', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'marathon.elimination': { + group: 'marathon', + name: 'elimination', + label: 'Runner Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'marathon.knockout': { + group: 'marathon', + name: 'knockout', + label: 'Runner Knocked', + evaluate: ({ state }) => state.pendingEvents.has('knockout'), + }, +} as const; + +export default MarathonConditions; diff --git a/app/services/stream-avatar/engine/conditions/marvelrivals.ts b/app/services/stream-avatar/engine/conditions/marvelrivals.ts new file mode 100644 index 000000000000..b21230a1ae98 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/marvelrivals.ts @@ -0,0 +1,64 @@ +import { ConditionDefinition } from '.'; + +export type MarvelRivalsConditionPropsMap = { + //---------------------- + // Marvel Rivals + //---------------------- + + // Game Flow + 'marvel_rivals.game_end': undefined; + + // Win / Lose + 'marvel_rivals.victory': undefined; + 'marvel_rivals.defeat': undefined; + + // Combat + 'marvel_rivals.elimination': undefined; + 'marvel_rivals.player_eliminated': undefined; +}; + +export type MarvelRivalsConditionType = keyof MarvelRivalsConditionPropsMap; +export type MarvelRivalsConditionProps< + T extends MarvelRivalsConditionType +> = MarvelRivalsConditionPropsMap[T]; + +export const MarvelRivalsConditions: { + [K in MarvelRivalsConditionType]: ConditionDefinition; +} = { + 'marvel_rivals.game_end': { + group: 'marvel_rivals', + name: 'game_end', + label: 'Match Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'marvel_rivals.victory': { + group: 'marvel_rivals', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'marvel_rivals.defeat': { + group: 'marvel_rivals', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'marvel_rivals.elimination': { + group: 'marvel_rivals', + name: 'elimination', + label: 'Hero Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'marvel_rivals.player_eliminated': { + group: 'marvel_rivals', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, +} as const; + +export default MarvelRivalsConditions; diff --git a/app/services/stream-avatar/engine/conditions/minecraft.ts b/app/services/stream-avatar/engine/conditions/minecraft.ts new file mode 100644 index 000000000000..5c9a117d8a00 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/minecraft.ts @@ -0,0 +1,100 @@ +import { ConditionDefinition } from '.'; + +export type MinecraftConditionPropsMap = { + //---------------------- + // Minecraft + //---------------------- + + // Boss Events + 'minecraft.ender_dragon_spawned': undefined; + 'minecraft.boss_killed': undefined; + 'minecraft.wither_spawned': undefined; + + // Progression + 'minecraft.advancement_made': undefined; + 'minecraft.first_diamond': undefined; + 'minecraft.nether_entered': undefined; + + // Player + 'minecraft.player_eliminated': undefined; + 'minecraft.low_health': undefined; + 'minecraft.totem_of_undying_used': undefined; +}; + +export type MinecraftConditionType = keyof MinecraftConditionPropsMap; +export type MinecraftConditionProps< + T extends MinecraftConditionType +> = MinecraftConditionPropsMap[T]; + +export const MinecraftConditions: { + [K in MinecraftConditionType]: ConditionDefinition; +} = { + 'minecraft.ender_dragon_spawned': { + group: 'minecraft', + name: 'ender_dragon_spawned', + label: 'Ender Dragon Spawned', + evaluate: ({ state }) => state.pendingEvents.has('ender_dragon_spawned'), + }, + + 'minecraft.boss_killed': { + group: 'minecraft', + name: 'boss_killed', + label: 'Boss Killed', + evaluate: ({ state }) => state.pendingEvents.has('boss_killed'), + }, + + 'minecraft.wither_spawned': { + group: 'minecraft', + name: 'wither_spawned', + label: 'Wither Spawned', + evaluate: ({ state }) => state.pendingEvents.has('wither_spawned'), + }, + + 'minecraft.advancement_made': { + group: 'minecraft', + name: 'advancement_made', + label: 'Advancement Made', + evaluate: ({ state }) => state.pendingEvents.has('advancement_made'), + }, + + 'minecraft.first_diamond': { + group: 'minecraft', + name: 'first_diamond', + label: 'First Diamond', + evaluate: ({ state }) => state.pendingEvents.has('first_diamond'), + }, + + 'minecraft.nether_entered': { + group: 'minecraft', + name: 'nether_entered', + label: 'Nether Entered', + evaluate: ({ state }) => state.pendingEvents.has('nether_entered'), + }, + + 'minecraft.player_eliminated': { + group: 'minecraft', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'minecraft.low_health': { + group: 'minecraft', + name: 'low_health', + label: 'Low Health', + evaluate: ({ state, prevState }) => { + const { health = 100 } = state; + const { health: prevHealth = 100 } = prevState; + return health < 50 && prevHealth >= 50; + }, + }, + + 'minecraft.totem_of_undying_used': { + group: 'minecraft', + name: 'totem_of_undying_used', + label: 'Totem of Undying Used', + evaluate: ({ state }) => state.pendingEvents.has('totem_of_undying_used'), + }, +} as const; + +export default MinecraftConditions; diff --git a/app/services/stream-avatar/engine/conditions/nba2k26.ts b/app/services/stream-avatar/engine/conditions/nba2k26.ts new file mode 100644 index 000000000000..06a448027d3e --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/nba2k26.ts @@ -0,0 +1,52 @@ +import { ConditionDefinition } from '.'; + +export type Nba2k26ConditionPropsMap = { + //---------------------- + // NBA 2K26 + //---------------------- + + // Game Flow + 'nba_2k26.game_start': undefined; + 'nba_2k26.game_end': undefined; + + // Match Events + 'nba_2k26.goal': undefined; + 'nba_2k26.halftime': undefined; +}; + +export type Nba2k26ConditionType = keyof Nba2k26ConditionPropsMap; +export type Nba2k26ConditionProps = Nba2k26ConditionPropsMap[T]; + +export const Nba2k26Conditions: { + [K in Nba2k26ConditionType]: ConditionDefinition; +} = { + 'nba_2k26.game_start': { + group: 'nba_2k26', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'nba_2k26.game_end': { + group: 'nba_2k26', + name: 'game_end', + label: 'Game Ended (Final)', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'nba_2k26.goal': { + group: 'nba_2k26', + name: 'goal', + label: 'Basket Scored', + evaluate: ({ state }) => state.pendingEvents.has('goal'), + }, + + 'nba_2k26.halftime': { + group: 'nba_2k26', + name: 'halftime', + label: 'Halftime', + evaluate: ({ state }) => state.pendingEvents.has('halftime'), + }, +} as const; + +export default Nba2k26Conditions; diff --git a/app/services/stream-avatar/engine/conditions/overwatch2.ts b/app/services/stream-avatar/engine/conditions/overwatch2.ts new file mode 100644 index 000000000000..0754fbf46422 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/overwatch2.ts @@ -0,0 +1,72 @@ +import { ConditionDefinition } from '.'; + +export type Overwatch2ConditionPropsMap = { + //---------------------- + // Overwatch 2 + //---------------------- + + // Game Flow + 'overwatch_2.round_start': undefined; + 'overwatch_2.round_end': undefined; + + // Win / Lose + 'overwatch_2.victory': undefined; + 'overwatch_2.defeat': undefined; + + // Combat + 'overwatch_2.elimination': undefined; + 'overwatch_2.player_eliminated': undefined; +}; + +export type Overwatch2ConditionType = keyof Overwatch2ConditionPropsMap; +export type Overwatch2ConditionProps< + T extends Overwatch2ConditionType +> = Overwatch2ConditionPropsMap[T]; + +export const Overwatch2Conditions: { + [K in Overwatch2ConditionType]: ConditionDefinition; +} = { + 'overwatch_2.round_start': { + group: 'overwatch_2', + name: 'round_start', + label: 'Round Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'overwatch_2.round_end': { + group: 'overwatch_2', + name: 'round_end', + label: 'Round Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'overwatch_2.victory': { + group: 'overwatch_2', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'overwatch_2.defeat': { + group: 'overwatch_2', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'overwatch_2.elimination': { + group: 'overwatch_2', + name: 'elimination', + label: 'Elimination', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'overwatch_2.player_eliminated': { + group: 'overwatch_2', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, +} as const; + +export default Overwatch2Conditions; diff --git a/app/services/stream-avatar/engine/conditions/pubg.ts b/app/services/stream-avatar/engine/conditions/pubg.ts new file mode 100644 index 000000000000..a371f88cd8d2 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/pubg.ts @@ -0,0 +1,150 @@ +import { Properties } from '../properties'; +import { ConditionDefinition } from '.'; + +export type PubgConditionPropsMap = { + //---------------------- + // PUBG + //---------------------- + + // Game Flow + 'pubg.game_started': undefined; + 'pubg.deployed': undefined; + 'pubg.storm_closing': undefined; + 'pubg.game_ended': undefined; + + // Player + 'pubg.victory': undefined; + 'pubg.player_eliminated': undefined; + 'pubg.player_knocked': undefined; + 'pubg.defeat': undefined; + + // Enemy + 'pubg.elimination': undefined; + 'pubg.knocked': undefined; + 'pubg.elimination_count': { elimination_count?: [number, number] }; + 'pubg.players_remaining': { players_remaining?: [number, number] }; +}; + +export type PubgConditionType = keyof PubgConditionPropsMap; +export type PubgConditionProps = PubgConditionPropsMap[T]; + +export const PubgConditions: { + [K in PubgConditionType]: ConditionDefinition; +} = { + // Game Flow + 'pubg.game_started': { + group: 'pubg', + name: 'game_started', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'pubg.deployed': { + group: 'pubg', + name: 'deployed', + label: 'Deployed', + evaluate: ({ state }) => state.pendingEvents.has('deploy'), + }, + + 'pubg.storm_closing': { + group: 'pubg', + name: 'storm_closing', + label: 'Storm Closing', + evaluate: ({ state }) => state.pendingEvents.has('storm_shrinking'), + }, + + 'pubg.game_ended': { + group: 'pubg', + name: 'game_ended', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + // Player + 'pubg.victory': { + group: 'pubg', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'pubg.defeat': { + group: 'pubg', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'pubg.player_eliminated': { + group: 'pubg', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'pubg.player_knocked': { + group: 'pubg', + name: 'player_knocked', + label: 'Player Knocked', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + // Enemy + 'pubg.elimination': { + group: 'pubg', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'pubg.knocked': { + group: 'pubg', + name: 'knocked', + label: 'Enemy Knocked', + evaluate: ({ state }) => state.pendingEvents.has('knockout'), + }, + + 'pubg.elimination_count': { + group: 'pubg', + name: 'elimination_count', + label: 'Enemy Elimination Count', + properties: { + elimination_count: new Properties.SliderRange({ + label: '# of Eliminations', + min: 0, + max: 50, + default: [5, 5], + step: 1, + }), + }, + evaluate: ({ state, prevState, props }) => { + const [min, max] = props?.elimination_count ?? [5, 5]; + const { eliminations = 0 } = state; + const { eliminations: prevEliminations = 0 } = prevState; + return eliminations >= min && prevEliminations <= max; + }, + }, + + 'pubg.players_remaining': { + group: 'pubg', + name: 'players_remaining', + label: 'Players Remaining (coming soon)', + disabled: true, + properties: { + players_remaining: new Properties.SliderRange({ + label: '# of Players Remaining', + min: 1, + max: 100, + default: [1, 1], + step: 1, + }), + }, + evaluate: ({ state, props }) => { + const [min, max] = props?.players_remaining ?? [1, 1]; + const { playersRemaining = 0 } = state; + return playersRemaining >= min && playersRemaining <= max; + }, + }, +} as const; + +export default PubgConditions; diff --git a/app/services/stream-avatar/engine/conditions/rainbowsixsiege.ts b/app/services/stream-avatar/engine/conditions/rainbowsixsiege.ts new file mode 100644 index 000000000000..15ea2724aea5 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/rainbowsixsiege.ts @@ -0,0 +1,80 @@ +import { ConditionDefinition } from '.'; + +export type RainbowSixSiegeConditionPropsMap = { + //---------------------- + // Rainbow Six Siege + //---------------------- + + // Game Flow + 'rainbow_six_siege.round_start': undefined; + 'rainbow_six_siege.action_phase': undefined; + 'rainbow_six_siege.round_end': undefined; + + // Win / Lose + 'rainbow_six_siege.victory': undefined; + 'rainbow_six_siege.defeat': undefined; + + // Combat + 'rainbow_six_siege.elimination': undefined; + 'rainbow_six_siege.player_eliminated': undefined; +}; + +export type RainbowSixSiegeConditionType = keyof RainbowSixSiegeConditionPropsMap; +export type RainbowSixSiegeConditionProps< + T extends RainbowSixSiegeConditionType +> = RainbowSixSiegeConditionPropsMap[T]; + +export const RainbowSixSiegeConditions: { + [K in RainbowSixSiegeConditionType]: ConditionDefinition; +} = { + 'rainbow_six_siege.round_start': { + group: 'rainbow_six_siege', + name: 'round_start', + label: 'Round Started (Preparation Phase)', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'rainbow_six_siege.action_phase': { + group: 'rainbow_six_siege', + name: 'action_phase', + label: 'Action Phase Started', + evaluate: ({ state }) => state.pendingEvents.has('action_phase'), + }, + + 'rainbow_six_siege.round_end': { + group: 'rainbow_six_siege', + name: 'round_end', + label: 'Round Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'rainbow_six_siege.victory': { + group: 'rainbow_six_siege', + name: 'victory', + label: 'Round Won', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'rainbow_six_siege.defeat': { + group: 'rainbow_six_siege', + name: 'defeat', + label: 'Round Lost', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'rainbow_six_siege.elimination': { + group: 'rainbow_six_siege', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'rainbow_six_siege.player_eliminated': { + group: 'rainbow_six_siege', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, +} as const; + +export default RainbowSixSiegeConditions; diff --git a/app/services/stream-avatar/engine/conditions/rocketleague.ts b/app/services/stream-avatar/engine/conditions/rocketleague.ts new file mode 100644 index 000000000000..36951193da49 --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/rocketleague.ts @@ -0,0 +1,72 @@ +import { ConditionDefinition } from '.'; + +export type RocketLeagueConditionPropsMap = { + //---------------------- + // Rocket League + //---------------------- + + // Game Flow + 'rocket_league.game_start': undefined; + 'rocket_league.game_end': undefined; + + // Scoring + 'rocket_league.team_scored': undefined; + 'rocket_league.opponent_scored': undefined; + + // Win / Lose + 'rocket_league.victory': undefined; + 'rocket_league.defeat': undefined; +}; + +export type RocketLeagueConditionType = keyof RocketLeagueConditionPropsMap; +export type RocketLeagueConditionProps< + T extends RocketLeagueConditionType +> = RocketLeagueConditionPropsMap[T]; + +export const RocketLeagueConditions: { + [K in RocketLeagueConditionType]: ConditionDefinition; +} = { + 'rocket_league.game_start': { + group: 'rocket_league', + name: 'game_start', + label: 'Game Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + 'rocket_league.game_end': { + group: 'rocket_league', + name: 'game_end', + label: 'Game Ended', + evaluate: ({ state }) => state.pendingEvents.has('game_end'), + }, + + 'rocket_league.team_scored': { + group: 'rocket_league', + name: 'team_scored', + label: 'Team Scored', + evaluate: ({ state }) => state.pendingEvents.has('team_scored'), + }, + + 'rocket_league.opponent_scored': { + group: 'rocket_league', + name: 'opponent_scored', + label: 'Opponent Scored', + evaluate: ({ state }) => state.pendingEvents.has('opponent_scored'), + }, + + 'rocket_league.victory': { + group: 'rocket_league', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'rocket_league.defeat': { + group: 'rocket_league', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, +} as const; + +export default RocketLeagueConditions; diff --git a/app/services/stream-avatar/engine/conditions/valorant.ts b/app/services/stream-avatar/engine/conditions/valorant.ts new file mode 100644 index 000000000000..019c2b61c33a --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/valorant.ts @@ -0,0 +1,102 @@ +import { Properties } from '../properties'; +import { ConditionDefinition } from '.'; + +export type ValorantConditionPropsMap = { + //---------------------- + // Valorant + //---------------------- + + // Game Flow + 'valorant.round_started': undefined; + + // Health Shield + 'valorant.low_health': undefined; + + // Player + 'valorant.victory': undefined; + 'valorant.player_eliminated': undefined; + 'valorant.defeat': undefined; + + // Enemy + 'valorant.elimination': undefined; + 'valorant.elimination_count': { elimination_count?: [number, number] }; +}; + +export type ValorantConditionType = keyof ValorantConditionPropsMap; +export type ValorantConditionProps = ValorantConditionPropsMap[T]; + +export const ValorantConditions: { + [K in ValorantConditionType]: ConditionDefinition; +} = { + // Game Flow + 'valorant.round_started': { + group: 'valorant', + name: 'round_started', + label: 'Round Started', + evaluate: ({ state }) => state.pendingEvents.has('game_start'), + }, + + // Health / Shield Conditions + 'valorant.low_health': { + group: 'valorant', + name: 'low_health', + label: 'Low Health', + evaluate: ({ state }) => { + const { health = 0 } = state; + return health > 0 && health < 50; + }, + }, + + // Player + 'valorant.victory': { + group: 'valorant', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'valorant.defeat': { + group: 'valorant', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'valorant.player_eliminated': { + group: 'valorant', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + // Enemy + 'valorant.elimination': { + group: 'valorant', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'valorant.elimination_count': { + group: 'valorant', + name: 'elimination_count', + label: 'Enemy Elimination Count', + properties: { + elimination_count: new Properties.SliderRange({ + label: '# of Eliminations', + min: 0, + max: 50, + default: [5, 5], + step: 1, + }), + }, + evaluate: ({ state, prevState, props }) => { + const [min, max] = props?.elimination_count ?? [5, 5]; + const { eliminations = 0 } = state; + const { eliminations: prevEliminations = 0 } = prevState; + return eliminations >= min && prevEliminations <= max; + }, + }, +} as const; + +export default ValorantConditions; diff --git a/app/services/stream-avatar/engine/conditions/warthunder.ts b/app/services/stream-avatar/engine/conditions/warthunder.ts new file mode 100644 index 000000000000..e23a568d4a1b --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/warthunder.ts @@ -0,0 +1,28 @@ +import { ConditionDefinition } from '.'; + +export type WarThunderConditionPropsMap = { + //---------------------- + // War Thunder + //---------------------- + + // Combat + 'war_thunder.elimination': undefined; +}; + +export type WarThunderConditionType = keyof WarThunderConditionPropsMap; +export type WarThunderConditionProps< + T extends WarThunderConditionType +> = WarThunderConditionPropsMap[T]; + +export const WarThunderConditions: { + [K in WarThunderConditionType]: ConditionDefinition; +} = { + 'war_thunder.elimination': { + group: 'war_thunder', + name: 'elimination', + label: 'Target Destroyed', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, +} as const; + +export default WarThunderConditions; diff --git a/app/services/stream-avatar/engine/conditions/warzone.ts b/app/services/stream-avatar/engine/conditions/warzone.ts new file mode 100644 index 000000000000..49b201091d9c --- /dev/null +++ b/app/services/stream-avatar/engine/conditions/warzone.ts @@ -0,0 +1,155 @@ +import { Properties } from '../properties'; +import { ConditionDefinition } from '.'; + +export type WarzoneConditionPropsMap = { + //---------------------- + // Warzone + //---------------------- + + // Game Flow + 'warzone.deploy': undefined; + 'warzone.gulag_start': undefined; + 'warzone.gulag_end': undefined; + 'warzone.spectating': undefined; + 'warzone.redeploying': undefined; + + // Player + 'warzone.victory': undefined; + 'warzone.player_knocked': undefined; + 'warzone.player_eliminated': undefined; + 'warzone.defeat': undefined; + + // Enemy + 'warzone.elimination': undefined; + 'warzone.knockout': undefined; + 'warzone.elimination_count': { elimination_count?: [number, number] }; + 'warzone.players_remaining': { players_remaining?: [number, number] }; +}; + +export type WarzoneConditionType = keyof WarzoneConditionPropsMap; +export type WarzoneConditionProps = WarzoneConditionPropsMap[T]; + +export const WarzoneConditions: { + [K in WarzoneConditionType]: ConditionDefinition; +} = { + 'warzone.deploy': { + group: 'warzone', + name: 'deploy', + label: 'Deploy', + evaluate: ({ state }) => state.pendingEvents.has('deploy'), + }, + + 'warzone.elimination': { + group: 'warzone', + name: 'elimination', + label: 'Enemy Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('elimination'), + }, + + 'warzone.knockout': { + group: 'warzone', + name: 'knockout', + label: 'Enemy Downed', + evaluate: ({ state }) => state.pendingEvents.has('knockout'), + }, + + 'warzone.player_knocked': { + group: 'warzone', + name: 'player_knocked', + label: 'Player Downed', + evaluate: ({ state }) => state.pendingEvents.has('player_knocked'), + }, + + 'warzone.player_eliminated': { + group: 'warzone', + name: 'player_eliminated', + label: 'Player Eliminated', + evaluate: ({ state }) => state.pendingEvents.has('death'), + }, + + 'warzone.victory': { + group: 'warzone', + name: 'victory', + label: 'Victory', + evaluate: ({ state }) => state.pendingEvents.has('victory'), + }, + + 'warzone.defeat': { + group: 'warzone', + name: 'defeat', + label: 'Defeat', + evaluate: ({ state }) => state.pendingEvents.has('defeat'), + }, + + 'warzone.gulag_start': { + group: 'warzone', + name: 'gulag_start', + label: 'Gulag Started', + evaluate: ({ state }) => state.pendingEvents.has('gulag_start'), + }, + + 'warzone.gulag_end': { + group: 'warzone', + name: 'gulag_end', + label: 'Gulag Ended', + evaluate: ({ state }) => state.pendingEvents.has('gulag_end'), + }, + + 'warzone.spectating': { + group: 'warzone', + name: 'spectating', + label: 'Spectating', + evaluate: ({ state }) => state.pendingEvents.has('spectating'), + }, + + 'warzone.redeploying': { + group: 'warzone', + name: 'redeploying', + label: 'Redeploying', + evaluate: ({ state }) => state.pendingEvents.has('redeploying'), + }, + + 'warzone.elimination_count': { + group: 'warzone', + name: 'elimination_count', + label: 'Enemy Elimination Count', + properties: { + elimination_count: new Properties.SliderRange({ + label: '# of Eliminations', + min: 0, + max: 50, + default: [5, 5], + step: 1, + }), + }, + evaluate: ({ state, prevState, props }) => { + const [min, max] = props?.elimination_count ?? [5, 5]; + const { eliminations = 0 } = state; + const { eliminations: prevEliminations = 0 } = prevState; + return eliminations >= min && prevEliminations <= max; + }, + }, + + 'warzone.players_remaining': { + group: 'warzone', + name: 'players_remaining', + label: 'Players Remaining (coming soon)', + disabled: true, + properties: { + players_remaining: new Properties.SliderRange({ + label: '# of Players Remaining', + min: 1, + max: 150, + default: [1, 1], + step: 1, + }), + }, + evaluate: ({ state, props }) => { + const [min, max] = props?.players_remaining ?? [1, 1]; + const { playersRemaining = 0 } = state; + return playersRemaining >= min && playersRemaining <= max; + }, + }, +} as const; + +export default WarzoneConditions; diff --git a/app/services/stream-avatar/engine/game-state.ts b/app/services/stream-avatar/engine/game-state.ts new file mode 100644 index 000000000000..a91bdf1ee4b2 --- /dev/null +++ b/app/services/stream-avatar/engine/game-state.ts @@ -0,0 +1,30 @@ +export interface GameState { + state: string; + frame?: string; + + health: number; + shield: number; + + eliminations: number; + totalEliminations: number; + + teamScore: number; + opponentScore: number; + + playersRemaining: number; + + pendingEvents: ReadonlySet; +} + +export const defaultGameState: GameState = { + state: 'stopped', + frame: undefined, + health: 100, + shield: 100, + eliminations: 0, + totalEliminations: 0, + teamScore: 0, + opponentScore: 0, + playersRemaining: 0, + pendingEvents: new Set(), +}; diff --git a/app/services/stream-avatar/engine/instructions/apexlegends.ts b/app/services/stream-avatar/engine/instructions/apexlegends.ts new file mode 100644 index 000000000000..66062d32a80e --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/apexlegends.ts @@ -0,0 +1,18 @@ +import { ApexLegendsConditionPropsMap } from "../conditions/apexlegends"; +type ApexLegendsInstructionType = keyof ApexLegendsConditionPropsMap; + +export const ApexLegendsInstructions: Record = { + "apex_legends.game_start": "React to the match starting. 8 words max.", + "apex_legends.deploy": "React to {player} jumping from the dropship. 8 words max.", + "apex_legends.storm_shrinking": "Warn about the ring closing. 8 words max.", + "apex_legends.game_end": "React to the match ending. 8 words max.", + "apex_legends.player_knocked": "React to {player} getting knocked. 8 words max.", + "apex_legends.player_revived": "React to {player} being revived. 8 words max.", + "apex_legends.player_eliminated": "React to {player} being eliminated. 8 words max.", + "apex_legends.victory": "Celebrate {player}'s squad being Champion! 8 words max.", + "apex_legends.defeat": "React to {player}'s squad being eliminated. 8 words max.", + "apex_legends.elimination": "React to an enemy being eliminated. 8 words max.", + "apex_legends.knockout": "React to {player} knocking an enemy. 8 words max.", +}; + +export default ApexLegendsInstructions; diff --git a/app/services/stream-avatar/engine/instructions/arcraiders.ts b/app/services/stream-avatar/engine/instructions/arcraiders.ts new file mode 100644 index 000000000000..45eda2ed9a38 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/arcraiders.ts @@ -0,0 +1,18 @@ +import { ArcRaidersConditionPropsMap } from "../conditions/arcraiders"; + +type ArcRaidersInstructionType = keyof ArcRaidersConditionPropsMap; + +export const ArcRaidersInstructions: Record = + { + "arc_raiders.game_start": "React to the raid starting. 8 words max.", + "arc_raiders.game_end": "React to the raid ending. 8 words max.", + "arc_raiders.victory": "Celebrate {player}'s victory! 8 words max.", + "arc_raiders.defeat": "React to {player}'s defeat. 8 words max.", + "arc_raiders.player_knocked": "React to {player} going down. 8 words max.", + "arc_raiders.player_eliminated": "React to {player} being eliminated. 8 words max.", + "arc_raiders.enemy_spotted": "React to an enemy being spotted. 8 words max.", + "arc_raiders.enemy_detected": "React to an enemy detected nearby. 8 words max.", + "arc_raiders.interesting_moment": "React to an interesting moment. 8 words max.", + }; + +export default ArcRaidersInstructions; diff --git a/app/services/stream-avatar/engine/instructions/battlefield6.ts b/app/services/stream-avatar/engine/instructions/battlefield6.ts new file mode 100644 index 000000000000..75ea1836b05e --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/battlefield6.ts @@ -0,0 +1,13 @@ +import { Battlefield6ConditionPropsMap } from "../conditions/battlefield6"; +type Battlefield6InstructionType = keyof Battlefield6ConditionPropsMap; + +export const Battlefield6Instructions: Record = { + "battlefield_6.game_start": "React to the battle starting. 8 words max.", + "battlefield_6.game_end": "React to the match ending. 8 words max.", + "battlefield_6.victory": "Celebrate {player}'s team winning! 8 words max.", + "battlefield_6.defeat": "React to {player}'s team losing. 8 words max.", + "battlefield_6.elimination": "React to an enemy being eliminated. 8 words max.", + "battlefield_6.player_eliminated": "React to {player} going down. 8 words max.", +}; + +export default Battlefield6Instructions; diff --git a/app/services/stream-avatar/engine/instructions/blackops6.ts b/app/services/stream-avatar/engine/instructions/blackops6.ts new file mode 100644 index 000000000000..a69eaf22c3d7 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/blackops6.ts @@ -0,0 +1,12 @@ +import { BlackOps6ConditionPropsMap } from "../conditions/blackops6"; + +type BlackOps6InstructionType = keyof BlackOps6ConditionPropsMap; + +export const BlackOps6Instructions: Record = { + "black_ops_6.elimination": "React to an enemy being eliminated. 8 words max.", + "black_ops_6.victory": "Celebrate {player}'s victory! 8 words max.", + "black_ops_6.defeat": "React to {player}'s defeat. 8 words max.", + "black_ops_6.spectating": "React to {player} now spectating. 8 words max.", +}; + +export default BlackOps6Instructions; diff --git a/app/services/stream-avatar/engine/instructions/counterstrike2.ts b/app/services/stream-avatar/engine/instructions/counterstrike2.ts new file mode 100644 index 000000000000..8a00acafb48d --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/counterstrike2.ts @@ -0,0 +1,23 @@ +import { CounterStrike2ConditionPropsMap } from "../conditions/counterstrike2"; +type CounterStrike2InstructionType = keyof CounterStrike2ConditionPropsMap; + +export const CounterStrike2Instructions: Record< + CounterStrike2InstructionType, + string +> = { + "counter_strike_2.round_started": "React to the round starting. 8 words max.", + "counter_strike_2.first_half": "React to the first half starting. 8 words max.", + "counter_strike_2.second_half": "React to the second half starting. 8 words max.", + "counter_strike_2.round_won": "React to {player}'s team winning the round. 8 words max.", + "counter_strike_2.round_lost": "React to {player}'s team losing the round. 8 words max.", + "counter_strike_2.game_ended": "React to the match ending. 8 words max.", + "counter_strike_2.low_health": "Panic about {player}'s low health. 8 words max.", + "counter_strike_2.victory": "Celebrate {player}'s team winning the match. 8 words max.", + "counter_strike_2.player_eliminated": "React to {player} being eliminated. 8 words max.", + "counter_strike_2.defeat": "React to {player}'s team losing the match. 8 words max.", + "counter_strike_2.elimination": "React to an enemy being eliminated. 8 words max.", + "counter_strike_2.elimination_count": + "React to {player} reaching {elimination_count} kills. 8 words max.", +}; + +export default CounterStrike2Instructions; diff --git a/app/services/stream-avatar/engine/instructions/deadbydaylight.ts b/app/services/stream-avatar/engine/instructions/deadbydaylight.ts new file mode 100644 index 000000000000..172daddb6f18 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/deadbydaylight.ts @@ -0,0 +1,14 @@ +import { DeadByDaylightConditionPropsMap } from "../conditions/deadbydaylight"; +type DeadByDaylightInstructionType = keyof DeadByDaylightConditionPropsMap; + +export const DeadByDaylightInstructions: Record = { + "dead_by_daylight.game_start": "React to the trial beginning. 8 words max.", + "dead_by_daylight.game_end": "React to the trial ending. 8 words max.", + "dead_by_daylight.victory": "React to the match ending in victory. 8 words max.", + "dead_by_daylight.player_eliminated": "React to {player} being sacrificed. 8 words max.", + "dead_by_daylight.elimination": "React to a survivor being sacrificed. 8 words max.", + "dead_by_daylight.hooked_survivor": "React to a survivor being hooked. 8 words max.", + "dead_by_daylight.escaped": "React to a survivor escaping. 8 words max.", +}; + +export default DeadByDaylightInstructions; diff --git a/app/services/stream-avatar/engine/instructions/deadlock.ts b/app/services/stream-avatar/engine/instructions/deadlock.ts new file mode 100644 index 000000000000..f3b7094807a9 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/deadlock.ts @@ -0,0 +1,13 @@ +import { DeadlockConditionPropsMap } from "../conditions/deadlock"; +type DeadlockInstructionType = keyof DeadlockConditionPropsMap; + +export const DeadlockInstructions: Record = { + "deadlock.game_start": "React to the match starting. 8 words max.", + "deadlock.game_end": "React to the match ending. 8 words max.", + "deadlock.victory": "Celebrate {player}'s team winning! 8 words max.", + "deadlock.defeat": "React to {player}'s team losing. 8 words max.", + "deadlock.elimination": "React to an enemy hero being eliminated. 8 words max.", + "deadlock.player_eliminated": "React to {player} being eliminated. 8 words max.", +}; + +export default DeadlockInstructions; diff --git a/app/services/stream-avatar/engine/instructions/dota2.ts b/app/services/stream-avatar/engine/instructions/dota2.ts new file mode 100644 index 000000000000..29129b84f32b --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/dota2.ts @@ -0,0 +1,15 @@ +import { Dota2ConditionPropsMap } from "../conditions/dota2"; +type Dota2InstructionType = keyof Dota2ConditionPropsMap; + +export const Dota2Instructions: Record = { + "dota_2.game_start": "React to the match starting. 8 words max.", + "dota_2.game_end": "React to the match ending. 8 words max.", + "dota_2.victory": "Celebrate {player}'s team destroying the Ancient! 8 words max.", + "dota_2.defeat": "React to {player}'s team losing. 8 words max.", + "dota_2.elimination": "React to a hero kill. 8 words max.", + "dota_2.player_eliminated": "React to {player}'s hero dying. 8 words max.", + "dota_2.tower_destroyed": "React to a tower being destroyed. 8 words max.", + "dota_2.glyph_used": "React to the Glyph being activated. 8 words max.", +}; + +export default Dota2Instructions; diff --git a/app/services/stream-avatar/engine/instructions/easportsfc26.ts b/app/services/stream-avatar/engine/instructions/easportsfc26.ts new file mode 100644 index 000000000000..3ad1787085a2 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/easportsfc26.ts @@ -0,0 +1,13 @@ +import { EaSportsFc26ConditionPropsMap } from "../conditions/easportsfc26"; +type EaSportsFc26InstructionType = keyof EaSportsFc26ConditionPropsMap; + +export const EaSportsFc26Instructions: Record = { + "ea_sports_fc_26.game_start": "React to the match kicking off. 8 words max.", + "ea_sports_fc_26.game_end": "React to the final whistle. 8 words max.", + "ea_sports_fc_26.goal": "React to a goal being scored! 8 words max.", + "ea_sports_fc_26.set_piece": "React to a set piece opportunity. 8 words max.", + "ea_sports_fc_26.halftime": "React to halftime. 8 words max.", + "ea_sports_fc_26.fulltime": "React to the full time whistle. 8 words max.", +}; + +export default EaSportsFc26Instructions; diff --git a/app/services/stream-avatar/engine/instructions/enshrouded.ts b/app/services/stream-avatar/engine/instructions/enshrouded.ts new file mode 100644 index 000000000000..f71605c2f490 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/enshrouded.ts @@ -0,0 +1,11 @@ +import { EnshroudedConditionPropsMap } from "../conditions/enshrouded"; +type EnshroudedInstructionType = keyof EnshroudedConditionPropsMap; + +export const EnshroudedInstructions: Record = { + "enshrouded.player_eliminated": "React to {player} being eliminated in Enshrouded. 8 words max.", + "enshrouded.level_up": "Celebrate {player} leveling up! 8 words max.", + "enshrouded.soul_discovered": "React to {player} discovering a soul. 8 words max.", + "enshrouded.quest_update": "React to {player}'s quest being updated. 8 words max.", +}; + +export default EnshroudedInstructions; diff --git a/app/services/stream-avatar/engine/instructions/f125.ts b/app/services/stream-avatar/engine/instructions/f125.ts new file mode 100644 index 000000000000..a6cbdab0110e --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/f125.ts @@ -0,0 +1,12 @@ +import { F125ConditionPropsMap } from "../conditions/f125"; +type F125InstructionType = keyof F125ConditionPropsMap; + +export const F125Instructions: Record = { + "f1_25.game_start": "React to the race starting. 8 words max.", + "f1_25.game_end": "React to {player} crossing the chequered flag. 8 words max.", + "f1_25.victory": "Celebrate {player} winning the race in P1! 8 words max.", + "f1_25.position_change": "React to {player}'s position changing on track. 8 words max.", + "f1_25.lap_change": "React to {player} starting a new lap. 8 words max.", +}; + +export default F125Instructions; diff --git a/app/services/stream-avatar/engine/instructions/fortnite.ts b/app/services/stream-avatar/engine/instructions/fortnite.ts new file mode 100644 index 000000000000..2b338747c210 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/fortnite.ts @@ -0,0 +1,30 @@ +import { FortniteConditionPropsMap } from "../conditions/fortnite"; +type FortniteInstructionType = keyof FortniteConditionPropsMap; + +export const FortniteInstructions: Record = { + "fortnite.game_started": "React to the game starting. 8 words max.", + "fortnite.deployed": "React to {player} dropping in. 8 words max.", + "fortnite.storm_closing": "Warn about the storm closing in. 8 words max.", + "fortnite.game_ended": "React to the game ending. 8 words max.", + "fortnite.low_health": + "Panic about {player}'s critically low health. 8 words max.", + "fortnite.has_shield": "React to {player} having shield. 8 words max.", + "fortnite.no_shield": + "Warn {player} they have no shield. 8 words max.", + "fortnite.victory_royale": + "Celebrate {player}'s Victory Royale! 8 words max.", + "fortnite.player_eliminated": + "React to {player} getting eliminated. 8 words max.", + "fortnite.player_knocked": + "React to {player} getting knocked. 8 words max.", + "fortnite.defeat": "React to {player}'s defeat. 8 words max.", + "fortnite.elimination": "React to the enemy being eliminated. 8 words max.", + "fortnite.knocked": + "React to {player} knocking an enemy. 8 words max.", + "fortnite.elimination_count": + "React to {player} reaching {elimination_count} eliminations. 8 words max.", + "fortnite.players_remaining": + "Comment on {players_remaining} players remaining. 8 words max.", +}; + +export default FortniteInstructions; diff --git a/app/services/stream-avatar/engine/instructions/forzahorizon6.ts b/app/services/stream-avatar/engine/instructions/forzahorizon6.ts new file mode 100644 index 000000000000..13c171ace9ce --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/forzahorizon6.ts @@ -0,0 +1,14 @@ +import { ForzaHorizon6ConditionPropsMap } from "../conditions/forzahorizon6"; +type ForzaHorizon6InstructionType = keyof ForzaHorizon6ConditionPropsMap; + +export const ForzaHorizon6Instructions: Record = { + "forza_horizon_6.game_start": "React to the race starting. 8 words max.", + "forza_horizon_6.game_end": "React to {player} finishing the race. 8 words max.", + "forza_horizon_6.position_change": "React to {player}'s race position changing. 8 words max.", + "forza_horizon_6.lap_change": "React to {player} starting a new lap. 8 words max.", + "forza_horizon_6.great_drift": "React to {player} pulling off an epic drift. 8 words max.", + "forza_horizon_6.great_air": "React to {player} catching massive air. 8 words max.", + "forza_horizon_6.great_skill_chain": "React to {player} landing a great skill chain. 8 words max.", +}; + +export default ForzaHorizon6Instructions; diff --git a/app/services/stream-avatar/engine/instructions/index.ts b/app/services/stream-avatar/engine/instructions/index.ts new file mode 100644 index 000000000000..5f73ea189880 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/index.ts @@ -0,0 +1,56 @@ +import FortniteInstructions from "./fortnite"; +import PubgInstructions from "./pubg"; +import ValorantInstructions from "./valorant"; +import CounterStrike2Instructions from "./counterstrike2"; +import WarzoneInstructions from "./warzone"; +import ArcRaidersInstructions from "./arcraiders"; +import BlackOps6Instructions from "./blackops6"; +import RocketLeagueInstructions from "./rocketleague"; +import MinecraftInstructions from "./minecraft"; +import ApexLegendsInstructions from "./apexlegends"; +import Battlefield6Instructions from "./battlefield6"; +import DeadByDaylightInstructions from "./deadbydaylight"; +import DeadlockInstructions from "./deadlock"; +import Dota2Instructions from "./dota2"; +import LeagueOfLegendsInstructions from "./leagueoflegends"; +import MarvelRivalsInstructions from "./marvelrivals"; +import Overwatch2Instructions from "./overwatch2"; +import RainbowSixSiegeInstructions from "./rainbowsixsiege"; +import WarThunderInstructions from "./warthunder"; +import MarathonInstructions from "./marathon"; +import F125Instructions from "./f125"; +import EaSportsFc26Instructions from "./easportsfc26"; +import Nba2k26Instructions from "./nba2k26"; +import ForzaHorizon6Instructions from "./forzahorizon6"; +import EnshroudedInstructions from "./enshrouded"; + +export const Instructions = { + ...FortniteInstructions, + ...PubgInstructions, + ...ValorantInstructions, + ...CounterStrike2Instructions, + ...WarzoneInstructions, + ...ArcRaidersInstructions, + ...BlackOps6Instructions, + ...RocketLeagueInstructions, + ...MinecraftInstructions, + ...ApexLegendsInstructions, + ...Battlefield6Instructions, + ...DeadByDaylightInstructions, + ...DeadlockInstructions, + ...Dota2Instructions, + ...LeagueOfLegendsInstructions, + ...MarvelRivalsInstructions, + ...Overwatch2Instructions, + ...RainbowSixSiegeInstructions, + ...WarThunderInstructions, + ...MarathonInstructions, + ...F125Instructions, + ...EaSportsFc26Instructions, + ...Nba2k26Instructions, + ...ForzaHorizon6Instructions, + ...EnshroudedInstructions, +} as const; + +export type InstructionType = keyof typeof Instructions; +export type TInstruction = InstructionType; diff --git a/app/services/stream-avatar/engine/instructions/leagueoflegends.ts b/app/services/stream-avatar/engine/instructions/leagueoflegends.ts new file mode 100644 index 000000000000..0fe6e135051e --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/leagueoflegends.ts @@ -0,0 +1,17 @@ +import { LeagueOfLegendsConditionPropsMap } from "../conditions/leagueoflegends"; +type LeagueOfLegendsInstructionType = keyof LeagueOfLegendsConditionPropsMap; + +export const LeagueOfLegendsInstructions: Record = { + "league_of_legends.game_start": "React to minions spawning and the match starting. 8 words max.", + "league_of_legends.game_end": "React to the match ending. 8 words max.", + "league_of_legends.victory": "Celebrate {player}'s team destroying the Nexus! 8 words max.", + "league_of_legends.defeat": "React to {player}'s team losing. 8 words max.", + "league_of_legends.elimination": "React to {player} getting a kill. 8 words max.", + "league_of_legends.player_eliminated": "React to {player}'s champion dying. 8 words max.", + "league_of_legends.objective_ally": "React to {player}'s team securing an objective. 8 words max.", + "league_of_legends.objective_enemy": "React to the enemy stealing an objective. 8 words max.", + "league_of_legends.enemy_turret_destroyed": "React to an enemy turret falling. 8 words max.", + "league_of_legends.ally_turret_destroyed": "React to an ally turret being lost. 8 words max.", +}; + +export default LeagueOfLegendsInstructions; diff --git a/app/services/stream-avatar/engine/instructions/marathon.ts b/app/services/stream-avatar/engine/instructions/marathon.ts new file mode 100644 index 000000000000..57b2f8bd555d --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/marathon.ts @@ -0,0 +1,15 @@ +import { MarathonConditionPropsMap } from "../conditions/marathon"; +type MarathonInstructionType = keyof MarathonConditionPropsMap; + +export const MarathonInstructions: Record = { + "marathon.game_start": "React to the extraction mission starting. 8 words max.", + "marathon.game_end": "React to the match ending. 8 words max.", + "marathon.victory": "Celebrate {player} successfully exfiltrating! 8 words max.", + "marathon.defeat": "React to {player} being eliminated before extraction. 8 words max.", + "marathon.player_knocked": "React to {player} going down. 8 words max.", + "marathon.player_eliminated": "React to {player} being eliminated. 8 words max.", + "marathon.elimination": "React to an enemy runner being eliminated. 8 words max.", + "marathon.knockout": "React to a runner being knocked down. 8 words max.", +}; + +export default MarathonInstructions; diff --git a/app/services/stream-avatar/engine/instructions/marvelrivals.ts b/app/services/stream-avatar/engine/instructions/marvelrivals.ts new file mode 100644 index 000000000000..9191078965bc --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/marvelrivals.ts @@ -0,0 +1,12 @@ +import { MarvelRivalsConditionPropsMap } from "../conditions/marvelrivals"; +type MarvelRivalsInstructionType = keyof MarvelRivalsConditionPropsMap; + +export const MarvelRivalsInstructions: Record = { + "marvel_rivals.game_end": "React to the match ending. 8 words max.", + "marvel_rivals.victory": "Celebrate {player}'s team winning! 8 words max.", + "marvel_rivals.defeat": "React to {player}'s team losing. 8 words max.", + "marvel_rivals.elimination": "React to a hero being eliminated. 8 words max.", + "marvel_rivals.player_eliminated": "React to {player}'s hero being eliminated. 8 words max.", +}; + +export default MarvelRivalsInstructions; diff --git a/app/services/stream-avatar/engine/instructions/minecraft.ts b/app/services/stream-avatar/engine/instructions/minecraft.ts new file mode 100644 index 000000000000..ba0dce8a88a6 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/minecraft.ts @@ -0,0 +1,27 @@ +import { MinecraftConditionPropsMap } from "../conditions/minecraft"; + +type MinecraftInstructionType = keyof MinecraftConditionPropsMap; + +export const MinecraftInstructions: Record = + { + "minecraft.ender_dragon_spawned": + "React to the Ender Dragon spawning. 8 words max.", + "minecraft.boss_killed": + "Celebrate {player} defeating the boss! 8 words max.", + "minecraft.wither_spawned": + "React to {player} summoning the Wither. 8 words max.", + "minecraft.advancement_made": + "React to {player} earning an advancement. 8 words max.", + "minecraft.first_diamond": + "Celebrate {player} finding diamonds! 8 words max.", + "minecraft.nether_entered": + "React to {player} entering the Nether. 8 words max.", + "minecraft.player_eliminated": + "React to {player} dying in Minecraft. 8 words max.", + "minecraft.low_health": + "Panic about {player}'s low health. 8 words max.", + "minecraft.totem_of_undying_used": + "React to {player} using a Totem of Undying. 8 words max.", + }; + +export default MinecraftInstructions; diff --git a/app/services/stream-avatar/engine/instructions/nba2k26.ts b/app/services/stream-avatar/engine/instructions/nba2k26.ts new file mode 100644 index 000000000000..200885673336 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/nba2k26.ts @@ -0,0 +1,11 @@ +import { Nba2k26ConditionPropsMap } from "../conditions/nba2k26"; +type Nba2k26InstructionType = keyof Nba2k26ConditionPropsMap; + +export const Nba2k26Instructions: Record = { + "nba_2k26.game_start": "React to the game tipping off. 8 words max.", + "nba_2k26.game_end": "React to the final buzzer. 8 words max.", + "nba_2k26.goal": "React to a basket being scored! 8 words max.", + "nba_2k26.halftime": "React to halftime. 8 words max.", +}; + +export default Nba2k26Instructions; diff --git a/app/services/stream-avatar/engine/instructions/overwatch2.ts b/app/services/stream-avatar/engine/instructions/overwatch2.ts new file mode 100644 index 000000000000..9a37d2439e2a --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/overwatch2.ts @@ -0,0 +1,13 @@ +import { Overwatch2ConditionPropsMap } from "../conditions/overwatch2"; +type Overwatch2InstructionType = keyof Overwatch2ConditionPropsMap; + +export const Overwatch2Instructions: Record = { + "overwatch_2.round_start": "React to the round starting. 8 words max.", + "overwatch_2.round_end": "React to the round ending. 8 words max.", + "overwatch_2.victory": "Celebrate {player}'s team winning! 8 words max.", + "overwatch_2.defeat": "React to {player}'s team losing. 8 words max.", + "overwatch_2.elimination": "React to an enemy being eliminated. 8 words max.", + "overwatch_2.player_eliminated": "React to {player} being eliminated. 8 words max.", +}; + +export default Overwatch2Instructions; diff --git a/app/services/stream-avatar/engine/instructions/pubg.ts b/app/services/stream-avatar/engine/instructions/pubg.ts new file mode 100644 index 000000000000..a3a867331162 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/pubg.ts @@ -0,0 +1,21 @@ +import { PubgConditionPropsMap } from "../conditions/pubg"; +type PubgInstructionType = keyof PubgConditionPropsMap; + +export const PubgInstructions: Record = { + "pubg.game_started": "React to the match starting. 8 words max.", + "pubg.deployed": "React to {player} parachuting in. 8 words max.", + "pubg.storm_closing": "Warn about the blue zone closing. 8 words max.", + "pubg.game_ended": "React to the match ending. 8 words max.", + "pubg.victory": "Celebrate {player}'s chicken dinner! 8 words max.", + "pubg.player_eliminated": "React to {player} being eliminated. 8 words max.", + "pubg.player_knocked": "React to {player} getting knocked. 8 words max.", + "pubg.defeat": "React to {player}'s defeat. 8 words max.", + "pubg.elimination": "React to an enemy being eliminated. 8 words max.", + "pubg.knocked": "React to {player} knocking an enemy. 8 words max.", + "pubg.elimination_count": + "React to {player} reaching {elimination_count} eliminations. 8 words max.", + "pubg.players_remaining": + "Comment on {players_remaining} players remaining. 8 words max.", +}; + +export default PubgInstructions; diff --git a/app/services/stream-avatar/engine/instructions/rainbowsixsiege.ts b/app/services/stream-avatar/engine/instructions/rainbowsixsiege.ts new file mode 100644 index 000000000000..9c77f11e294b --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/rainbowsixsiege.ts @@ -0,0 +1,14 @@ +import { RainbowSixSiegeConditionPropsMap } from "../conditions/rainbowsixsiege"; +type RainbowSixSiegeInstructionType = keyof RainbowSixSiegeConditionPropsMap; + +export const RainbowSixSiegeInstructions: Record = { + "rainbow_six_siege.round_start": "React to the preparation phase starting. 8 words max.", + "rainbow_six_siege.action_phase": "React to the action phase beginning. 8 words max.", + "rainbow_six_siege.round_end": "React to the round ending. 8 words max.", + "rainbow_six_siege.victory": "React to {player}'s team winning the round. 8 words max.", + "rainbow_six_siege.defeat": "React to {player}'s team losing the round. 8 words max.", + "rainbow_six_siege.elimination": "React to an enemy operator being eliminated. 8 words max.", + "rainbow_six_siege.player_eliminated": "React to {player} being eliminated. 8 words max.", +}; + +export default RainbowSixSiegeInstructions; diff --git a/app/services/stream-avatar/engine/instructions/rocketleague.ts b/app/services/stream-avatar/engine/instructions/rocketleague.ts new file mode 100644 index 000000000000..efefa8838bf1 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/rocketleague.ts @@ -0,0 +1,17 @@ +import { RocketLeagueConditionPropsMap } from "../conditions/rocketleague"; + +type RocketLeagueInstructionType = keyof RocketLeagueConditionPropsMap; + +export const RocketLeagueInstructions: Record< + RocketLeagueInstructionType, + string +> = { + "rocket_league.game_start": "React to the match starting. 8 words max.", + "rocket_league.game_end": "React to the match ending. 8 words max.", + "rocket_league.team_scored": "React to {player}'s team scoring a goal. 8 words max.", + "rocket_league.opponent_scored": "React to the opponent scoring. 8 words max.", + "rocket_league.victory": "Celebrate {player}'s Rocket League win! 8 words max.", + "rocket_league.defeat": "React to {player}'s Rocket League loss. 8 words max.", +}; + +export default RocketLeagueInstructions; diff --git a/app/services/stream-avatar/engine/instructions/valorant.ts b/app/services/stream-avatar/engine/instructions/valorant.ts new file mode 100644 index 000000000000..9702f1578544 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/valorant.ts @@ -0,0 +1,15 @@ +import { ValorantConditionPropsMap } from "../conditions/valorant"; +type ValorantInstructionType = keyof ValorantConditionPropsMap; + +export const ValorantInstructions: Record = { + "valorant.round_started": "React to the round starting. 8 words max.", + "valorant.low_health": "Panic about {player}'s low health. 8 words max.", + "valorant.victory": "Celebrate {player}'s team winning. 8 words max.", + "valorant.player_eliminated": "React to {player} being eliminated. 8 words max.", + "valorant.defeat": "React to {player}'s team losing. 8 words max.", + "valorant.elimination": "React to an enemy being eliminated. 8 words max.", + "valorant.elimination_count": + "React to {player} reaching {elimination_count} kills. 8 words max.", +}; + +export default ValorantInstructions; diff --git a/app/services/stream-avatar/engine/instructions/warthunder.ts b/app/services/stream-avatar/engine/instructions/warthunder.ts new file mode 100644 index 000000000000..2e80d29933ff --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/warthunder.ts @@ -0,0 +1,8 @@ +import { WarThunderConditionPropsMap } from "../conditions/warthunder"; +type WarThunderInstructionType = keyof WarThunderConditionPropsMap; + +export const WarThunderInstructions: Record = { + "war_thunder.elimination": "React to {player} destroying an enemy. 8 words max.", +}; + +export default WarThunderInstructions; diff --git a/app/services/stream-avatar/engine/instructions/warzone.ts b/app/services/stream-avatar/engine/instructions/warzone.ts new file mode 100644 index 000000000000..cdcd7773fa83 --- /dev/null +++ b/app/services/stream-avatar/engine/instructions/warzone.ts @@ -0,0 +1,22 @@ +import { WarzoneConditionPropsMap } from "../conditions/warzone"; +type WarzoneInstructionType = keyof WarzoneConditionPropsMap; + +export const WarzoneInstructions: Record = { + "warzone.deploy": "React to {player} deploying in. 8 words max.", + "warzone.gulag_start": "React to {player} being sent to the Gulag. 8 words max.", + "warzone.gulag_end": "React to {player}'s Gulag result. 8 words max.", + "warzone.spectating": "React to {player} getting eliminated and spectating. 8 words max.", + "warzone.redeploying": "React to {player} redeploying back in. 8 words max.", + "warzone.victory": "Celebrate {player}'s Warzone victory! 8 words max.", + "warzone.player_knocked": "React to {player} getting knocked. 8 words max.", + "warzone.player_eliminated": "React to {player} being eliminated. 8 words max.", + "warzone.defeat": "React to {player}'s defeat. 8 words max.", + "warzone.elimination": "React to an enemy being eliminated. 8 words max.", + "warzone.knockout": "React to an enemy getting knocked. 8 words max.", + "warzone.elimination_count": + "React to {player} reaching {elimination_count} eliminations. 8 words max.", + "warzone.players_remaining": + "Comment on {players_remaining} players remaining. 8 words max.", +}; + +export default WarzoneInstructions; diff --git a/app/services/stream-avatar/engine/properties.ts b/app/services/stream-avatar/engine/properties.ts new file mode 100644 index 000000000000..165faea13e90 --- /dev/null +++ b/app/services/stream-avatar/engine/properties.ts @@ -0,0 +1,71 @@ +import type { ActionContext } from './actions'; + +type MaybePromise = T | Promise; + +abstract class PropertyBase & { default?: T }, E = T> { + value: T; + config: Config; + + constructor(config: Config) { + this.config = config; + this.value = config?.default as T; + } + + valueFromExport(v: E, _context: ActionContext): MaybePromise { + return v as unknown as T; + } + valueToExport(v: T, _context: ActionContext): MaybePromise { + return v as unknown as E; + } +} + +class SceneProperty extends PropertyBase<{ id: string }, { label: string }, { name: string }> { + override valueFromExport(v: { name: string }, { resolveSceneId }: ActionContext) { + return resolveSceneId(v).then(scene => ({ id: scene.id })); + } + override valueToExport(v: { id: string }, { resolveSceneId }: ActionContext) { + return resolveSceneId(v).then(scene => ({ name: scene.name })); + } +} + +class SourceProperty extends PropertyBase<{ id: string }, { label: string }, { name: string }> { + override valueFromExport(v: { name: string }, { resolveSourceId }: ActionContext) { + return resolveSourceId(v).then(source => ({ id: source.id })); + } + override valueToExport(v: { id: string }, { resolveSourceId }: ActionContext) { + return resolveSourceId(v).then(source => ({ name: source.name })); + } +} + +class SliderProperty extends PropertyBase< + number, + { label: string; default: number; min: number; max: number; step: number; format?: (v: number) => string } +> {} + +class SliderRangeProperty extends PropertyBase< + [number, number], + { label: string; default: [number, number]; min: number; max: number; step: number; format?: (v: [number, number]) => string } +> {} + +class CheckboxProperty extends PropertyBase {} + +class TextProperty extends PropertyBase { + override valueFromExport(v: string) { + return v; + } + override valueToExport(v: string) { + return v; + } +} + +export const Properties = { + Scene: SceneProperty, + Source: SourceProperty, + Slider: SliderProperty, + SliderRange: SliderRangeProperty, + Checkbox: CheckboxProperty, + Text: TextProperty, +} as const; + +export type PropertyMap = Record | undefined; +export type PropertyInstance = InstanceType<(typeof Properties)[keyof typeof Properties]>; diff --git a/app/services/stream-avatar/engine/validation.ts b/app/services/stream-avatar/engine/validation.ts new file mode 100644 index 000000000000..6423ed432a65 --- /dev/null +++ b/app/services/stream-avatar/engine/validation.ts @@ -0,0 +1,159 @@ +import { $t } from 'services/i18n'; +import { Conditions } from './conditions'; +import type { TAutomationExport } from './automations'; +import type { ExportedAction, ExportedActionProps } from './actions'; + +/** A scene or source that currently exists in the active scene collection. */ +export interface IResourceRef { + id: string; + name: string; +} + +/** Live scenes/sources used to detect deleted or unavailable references. */ +export interface IAvailableResources { + scenes: IResourceRef[]; + sources: IResourceRef[]; +} + +/** Where an issue applies, so the editor can render it next to the right field. */ +export type TIssueScope = 'description' | 'conditions' | 'action'; + +export interface IAutomationIssue { + scope: TIssueScope; + /** Index into `automation.actions` when scope === 'action'. */ + actionIndex?: number; + /** The offending prop, e.g. 'scene' | 'source' | 'instruction'. */ + field?: string; + message: string; +} + +export const MAX_DESCRIPTION_LENGTH = 100; +export const MAX_INSTRUCTION_LENGTH = 128; + +/** + * Translates `key` and substitutes `%{name}` placeholders. VueI18n only + * interpolates keys that exist in the dictionary; these strings are new and not + * synced yet, so we fill any placeholders left in the returned key ourselves. + */ +function t(key: string, vars?: Record): string { + const translated = vars ? $t(key, vars) : $t(key); + if (!vars) return translated; + return translated.replace(/%\{(\w+)\}/g, (match, name) => + name in vars ? String(vars[name]) : match, + ); +} + +function validateAction( + action: ExportedAction, + index: number, + resources: IAvailableResources, +): IAutomationIssue[] { + const issues: IAutomationIssue[] = []; + const props = (action?.props ?? {}) as ExportedActionProps; + + const actionIssue = (field: string, message: string): IAutomationIssue => ({ + scope: 'action', + actionIndex: index, + field, + message, + }); + + switch (action?.type) { + case 'common.switch_to_scene': { + const name = props.scene?.name?.trim(); + if (!name) { + issues.push(actionIssue('scene', $t('Select a scene to switch to.'))); + } else if (!resources.scenes.some(s => s.name === name)) { + issues.push( + actionIssue('scene', t('Scene "%{name}" no longer exists.', { name })), + ); + } + break; + } + + case 'common.show_source': + case 'common.hide_source': { + const name = props.source?.name?.trim(); + if (!name) { + issues.push(actionIssue('source', $t('Select a source.'))); + } else if (!resources.sources.some(s => s.name === name)) { + issues.push( + actionIssue('source', t('Source "%{name}" is unavailable.', { name })), + ); + } + break; + } + + case 'co-host.instruction': { + const instruction = props.instruction?.trim(); + if (!instruction) { + issues.push(actionIssue('instruction', $t('Enter an instruction for the co-host.'))); + } else if (instruction.length > MAX_INSTRUCTION_LENGTH) { + issues.push( + actionIssue( + 'instruction', + t('Instruction must be %{max} characters or fewer.', { + max: MAX_INSTRUCTION_LENGTH, + }), + ), + ); + } + break; + } + + default: + break; + } + + return issues; +} + +/** + * Validates an automation against the live scenes/sources. Returns every issue + * found so the UI can both block submission and surface real-time errors (e.g. + * a Switch Scene action pointing at a scene that has since been deleted). + */ +export function validateAutomation( + automation: TAutomationExport, + resources: IAvailableResources, +): IAutomationIssue[] { + const issues: IAutomationIssue[] = []; + + const description = automation.description?.trim(); + if (!description) { + issues.push({ scope: 'description', message: $t('Add a description.') }); + } else if (description.length > MAX_DESCRIPTION_LENGTH) { + issues.push({ + scope: 'description', + message: t('Description must be %{max} characters or fewer.', { + max: MAX_DESCRIPTION_LENGTH, + }), + }); + } + + if (!automation.conditions?.length) { + issues.push({ scope: 'conditions', message: $t('Select a condition.') }); + } else if ( + automation.conditions.some(c => !c?.type || !Conditions[c.type as keyof typeof Conditions]) + ) { + issues.push({ scope: 'conditions', message: $t('This automation uses an unknown condition.') }); + } + + const validActions = (automation.actions ?? []).filter(a => a?.type); + if (!validActions.length) { + issues.push({ scope: 'action', message: $t('Add at least one action.') }); + } + + (automation.actions ?? []).forEach((action, index) => { + issues.push(...validateAction(action, index, resources)); + }); + + return issues; +} + +export function isAutomationValid( + automation: TAutomationExport, + resources: IAvailableResources, +): boolean { + return validateAutomation(automation, resources).length === 0; +} diff --git a/app/services/stream-avatar/stream-avatar-api-service.ts b/app/services/stream-avatar/stream-avatar-api-service.ts new file mode 100644 index 000000000000..8c3a45400be7 --- /dev/null +++ b/app/services/stream-avatar/stream-avatar-api-service.ts @@ -0,0 +1,70 @@ +import { Service } from 'services/core'; +import { Inject } from 'services/core/injector'; +import { UserService } from 'services/user'; +import { HostsService } from 'services/hosts'; +import { authorizedHeaders, jfetch } from 'util/requests'; +import Util from 'services/utils'; + +export class StreamAvatarApiService extends Service { + @Inject() private userService: UserService; + @Inject() private hostsService: HostsService; + + private cachedJwt: string | null = null; + private cachedJwtExp = 0; + private inflight: Promise | null = null; + + private get apiBase(): string { + const protocol = Util.shouldUseAvatarLocalHost() ? 'http://' : 'https://'; + return `${protocol}${this.hostsService.streamAvatarApi}`; + } + + async getToken(forceRefresh = false): Promise { + const now = Date.now() / 1000; + if (!forceRefresh && this.cachedJwt && this.cachedJwtExp - now > 60) { + return this.cachedJwt; + } + + if (this.inflight) return this.inflight; + + this.inflight = this.mintToken().finally(() => { + this.inflight = null; + }); + + return this.inflight; + } + + private async mintToken(): Promise { + const response = await jfetch<{ token: string }>( + new Request(`${this.apiBase}/token/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: this.userService.apiToken }), + }), + ); + + const jwt = response.token; + // Decode the exp from the JWT payload (second base64url segment) + const payloadB64 = jwt.split('.')[1]; + const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))); + this.cachedJwt = jwt; + this.cachedJwtExp = payload.exp; + return jwt; + } + + async authedFetch(path: string, init: RequestInit = {}): Promise { + const makeRequest = (token: string) => + new Request(`${this.apiBase}${path}`, { + ...init, + headers: authorizedHeaders(token, new Headers({ 'Content-Type': 'application/json' })), + }); + + try { + return await jfetch(makeRequest(await this.getToken())); + } catch (e: any) { + if (e?.status === 401) { + return await jfetch(makeRequest(await this.getToken(true))); + } + throw e; + } + } +} diff --git a/app/services/utils.ts b/app/services/utils.ts index b647be708238..ccd2853fc7fb 100644 --- a/app/services/utils.ts +++ b/app/services/utils.ts @@ -133,6 +133,10 @@ export default class Utils { return Utils.env.SLOBS_USE_LOCAL_HOST as boolean; } + static shouldUseAvatarLocalHost(): boolean { + return true; + } + static shouldUseBeta(): boolean { return (process.env.SLD_COMPILE_FOR_BETA || Utils.env.SLD_USE_BETA) as boolean; } diff --git a/app/services/windows.ts b/app/services/windows.ts index 9a21eb7485b2..67f90397ace4 100644 --- a/app/services/windows.ts +++ b/app/services/windows.ts @@ -38,6 +38,7 @@ import { PlatformAppPopOut, RecentEventsWindow, EditTransform, + EditAutomations, Blank, Main, MultistreamChatInfo, @@ -99,6 +100,7 @@ export function getComponents() { MediaGallery, PlatformAppPopOut, EditTransform, + EditAutomations, OverlayPlaceholder, BrowserSourceInteraction, EventFilterMenu, From 7ffcc7a22ac819c7445406d55c3b691bc31d6773 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 3 Jun 2026 17:35:14 -0700 Subject: [PATCH 34/79] feat(automations): implement drag-and-drop reordering and enhance action editor functionality --- .../AutomationEditor.tsx | 414 ++++++++++++------ app/i18n/en-US/stream-avatar-automations.json | 4 + 2 files changed, 280 insertions(+), 138 deletions(-) diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index b16f42bc7813..58487a26bbfb 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -1,4 +1,6 @@ import React, { CSSProperties, useEffect, useState } from 'react'; +import { ReactSortable } from 'react-sortablejs'; +import uuid from 'uuid/v4'; import { ModalLayout } from 'components-react/shared/ModalLayout'; import { useVuex } from 'components-react/hooks'; import { Services } from 'components-react/service-provider'; @@ -26,6 +28,47 @@ function fieldBorder(invalid: boolean): CSSProperties { return { borderColor: invalid ? 'var(--red)' : 'var(--border)' }; } +// Drag-and-drop reordering needs a stable key per row that survives reorders and +// inserts (using the array index would make React/Sortable lose track on move). +interface ActionRow { + id: string; + action: ExportedAction; +} + +function makeRow(action: ExportedAction): ActionRow { + return { id: uuid(), action: withActionDefaults(action) }; +} + +// Height of a single control line, so the grip and +/- icons align with the +// action's primary input regardless of any extra rows (checkbox, slider) below. +const CONTROL_HEIGHT = '32px'; + +const dragHandleStyle: CSSProperties = { + cursor: 'grab', + color: 'var(--paragraph)', + fontSize: '16px', +}; + +const gripCellStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + height: CONTROL_HEIGHT, +}; + +const actionsCellStyle: CSSProperties = { + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + gap: '10px', + height: CONTROL_HEIGHT, +}; + +const iconButtonStyle: CSSProperties = { + cursor: 'pointer', + color: 'var(--icon-active)', + fontSize: '16px', +}; + const ACTION_OPTIONS = Object.entries(ActionRegistry).map(([type, def]) => ({ type: type as ActionType, label: def.label, @@ -48,6 +91,13 @@ const inputStyle: CSSProperties = { boxSizing: 'border-box', }; +// Full-width control that lines up with the row's fixed control height. +const rowControlStyle: CSSProperties = { + ...inputStyle, + width: '100%', + height: CONTROL_HEIGHT, +}; + const labelStyle: CSSProperties = { display: 'block', marginBottom: '4px', @@ -58,20 +108,24 @@ const labelStyle: CSSProperties = { interface ActionEditorProps { action: ExportedAction; index: number; + isFirst: boolean; scenes: { id: string; name: string }[]; sources: { id: string; name: string }[]; errors?: Record; onChange: (index: number, action: ExportedAction) => void; + onInsert: (index: number) => void; onRemove: (index: number) => void; } function ActionEditor({ action, index, + isFirst, scenes, sources, errors, onChange, + onInsert, onRemove, }: ActionEditorProps) { function setType(type: ActionType) { @@ -92,134 +146,193 @@ function ActionEditor({ const sourceName = props.source?.name ?? ''; const sourceMissing = !!sourceName && !sources.some(s => s.name === sourceName); + // The action's inline control (column 3), stacked vertically when it has more + // than one row (e.g. a source select plus its checkbox). + function renderControl() { + switch (action.type) { + case 'common.switch_to_scene': + return ( + <> + + {errors?.scene &&

{errors.scene}

} + + ); + + case 'common.show_source': + case 'common.hide_source': + return ( + <> + + {errors?.source &&

{errors.source}

} + {action.type === 'common.show_source' && ( + + )} + {action.type === 'common.hide_source' && ( + + )} + + ); + + case 'common.wait_for_ms': + return ( + <> +
+ {$t('Duration')} + + {((props.duration ?? 5000) / 1000).toFixed(1)} {$t('seconds')} + +
+ setProp('duration', Number(e.target.value))} + style={{ width: '100%' }} + /> + + ); + + case 'co-host.instruction': + return ( + <> + setProp('instruction', e.target.value)} + placeholder={$t('Instruction')} + maxLength={MAX_INSTRUCTION_LENGTH} + style={{ ...rowControlStyle, ...fieldBorder(!!errors?.instruction) }} + /> + {errors?.instruction &&

{errors.instruction}

} + + ); + + case 'co-host.comment': + return ( +

+ {$t('The co-host will automatically comment based on the active game condition.')} +

+ ); + + default: + return null; + } + } + return (
-
- - +
+
- {action.type === 'common.switch_to_scene' && ( - <> - - {errors?.scene &&

{errors.scene}

} - - )} - - {(action.type === 'common.show_source' || action.type === 'common.hide_source') && ( -
- - {errors?.source &&

{errors.source}

} - {action.type === 'common.show_source' && ( - - )} - {action.type === 'common.hide_source' && ( - - )} -
- )} - - {action.type === 'common.wait_for_ms' && ( -
- - setProp('duration', Number(e.target.value))} - style={{ width: '100%' }} - /> -
- )} - - {action.type === 'co-host.comment' && ( -

- {$t('The co-host will automatically comment based on the active game condition.')} -

- )} + + +
+ {renderControl()} +
- {action.type === 'co-host.instruction' && ( - <> - setProp('instruction', e.target.value)} - placeholder={$t('Instruction')} - maxLength={MAX_INSTRUCTION_LENGTH} - style={{ ...inputStyle, width: '100%', ...fieldBorder(!!errors?.instruction) }} +
+ onInsert(index)} + /> + {isFirst ? ( + + ) : ( + onRemove(index)} /> - {errors?.instruction &&

{errors.instruction}

} - - )} + )} +
); } @@ -247,11 +360,15 @@ export default function AutomationEditor({ initial, onClose }: Props) { const [conditionType, setConditionType] = useState(() => { return (initial?.conditions?.[0]?.type as ConditionType) ?? ''; }); - const [actions, setActions] = useState( - (initial?.actions as ExportedAction[])?.filter(a => a?.type).map(withActionDefaults) ?? [ - withActionDefaults({ type: 'common.save_replay' }), - ], + const [rows, setRows] = useState( + () => + (initial?.actions as ExportedAction[])?.filter(a => a?.type).map(makeRow) ?? [ + makeRow({ type: 'common.save_replay' }), + ], ); + // `rows` carries the stable drag keys; `actions` is the plain ordered list the + // validator and save payload consume. + const actions = rows.map(r => r.action); // Enable/disable is managed from the list, not here; new automations default // to enabled and edits preserve the existing value. const enabled = initial?.enabled ?? true; @@ -302,15 +419,25 @@ export default function AutomationEditor({ initial, onClose }: Props) { }, [selectedGame]); function handleActionChange(index: number, action: ExportedAction) { - setActions(prev => prev.map((a, i) => (i === index ? action : a))); + setRows(prev => prev.map((r, i) => (i === index ? { ...r, action } : r))); } function handleActionRemove(index: number) { - setActions(prev => prev.filter((_, i) => i !== index)); + setRows(prev => prev.filter((_, i) => i !== index)); } function handleAddAction() { - setActions(prev => [...prev, withActionDefaults({ type: 'common.save_replay' })]); + setRows(prev => [...prev, makeRow({ type: 'common.save_replay' })]); + } + + // Insert a new action directly after `afterIndex` so steps can be added + // between existing ones, not just appended. + function handleInsertAction(afterIndex: number) { + setRows(prev => { + const next = [...prev]; + next.splice(afterIndex + 1, 0, makeRow({ type: 'common.save_replay' })); + return next; + }); } async function handleSave() { @@ -421,18 +548,29 @@ export default function AutomationEditor({ initial, onClose }: Props) { {/* Actions */}
- {actions.map((action, i) => ( - - ))} + + list={rows} + setList={setRows} + handle=".sa-action-drag-handle" + animation={200} + tag="div" + > + {rows.map((row, i) => ( +
+ +
+ ))} + {actionsError &&

{actionsError}

} ); @@ -533,7 +547,12 @@ export default function AutomationEditor({ initial, onClose }: Props) { setProp('scene', { name: e.target.value })} - style={{ ...rowControlStyle, ...fieldBorder(!!errors?.scene) }} - > - - {sceneMissing && ( - - )} - {scenes.map(s => ( - - ))} - + setProp('source', { name: e.target.value })} - style={{ ...rowControlStyle, ...fieldBorder(!!errors?.source) }} - > - - {sourceMissing && ( - - )} - {sources.map(s => ( - - ))} - + setProp('hide_if_condition_false', e.target.checked)} - /> - {$t('Hide if condition is false')} - + setProp('hide_if_condition_false', val)} + label={$t('Hide if condition is false')} + /> )} {action.type === 'common.hide_source' && ( - + setProp('show_if_condition_false', val)} + label={$t('Show if condition is false')} + /> )} ); + } case 'common.wait_for_ms': return ( @@ -239,14 +209,14 @@ function ActionEditor({ {((props.duration ?? 5000) / 1000).toFixed(1)} {$t('seconds')}
- setProp('duration', val)} min={500} max={60000} step={500} - value={props.duration ?? 5000} - onChange={e => setProp('duration', Number(e.target.value))} - style={{ width: '100%' }} + tipFormatter={(val: number) => `${(val / 1000).toFixed(1)}s`} /> ); @@ -254,13 +224,11 @@ function ActionEditor({ case 'co-host.instruction': return ( <> - setProp('instruction', e.target.value)} placeholder={$t('Instruction')} maxLength={MAX_INSTRUCTION_LENGTH} - style={{ ...rowControlStyle, ...fieldBorder(!!errors?.instruction) }} /> {errors?.instruction &&

{errors.instruction}

} @@ -306,17 +274,12 @@ function ActionEditor({ />
- + onChange={val => setType(val as ActionType)} + style={{ width: '100%' }} + options={ACTION_OPTIONS} + />
{renderControl()} @@ -362,7 +325,7 @@ export default function AutomationEditor({ initial, onClose }: Props) { if (initial?.conditions?.[0]) { return (initial.conditions[0].type as string).split('.')[0]; } - return GAME_OPTIONS[0].id; + return GAME_OPTIONS[0].value; }); const [conditionType, setConditionType] = useState(() => { return (initial?.conditions?.[0]?.type as ConditionType) ?? ''; @@ -418,8 +381,8 @@ export default function AutomationEditor({ initial, onClose }: Props) { useEffect(() => { // Reset condition selection when game changes if (conditionOptions.length > 0) { - const current = conditionOptions.find(o => o.type === conditionType); - if (!current) setConditionType(conditionOptions[0].type); + const current = conditionOptions.find(o => o.value === conditionType); + if (!current) setConditionType(conditionOptions[0].value); } else { setConditionType(''); } @@ -478,12 +441,8 @@ export default function AutomationEditor({ initial, onClose }: Props) { } const getButtonText = () => { - if (saving) { - return $t('Saving...'); - } - if (initial) { - return $t('Save Changes'); - } + if (saving) return $t('Saving...'); + if (initial) return $t('Save Changes'); return $t('Create Automation'); }; @@ -511,58 +470,34 @@ export default function AutomationEditor({ initial, onClose }: Props) {
{/* Description */} -
- - setDescription(e.target.value)} - maxLength={100} - placeholder={$t('e.g. Victory Royale reaction')} - style={{ - ...inputStyle, - width: '100%', - padding: '6px 8px', - ...fieldBorder(!!descriptionError), - }} - /> - {descriptionError &&

{descriptionError}

} -
+ setDescription(val)} + placeholder={$t('e.g. Victory Royale reaction')} + validateStatus={descriptionError ? 'error' : undefined} + help={descriptionError} + /> {/* Condition */}
- - + onChange={val => setSelectedGame(val)} + style={{ flex: '0 0 auto' }} + options={GAME_OPTIONS} + /> +
- - - - + + + + - {automations.map(automation => { + {filtered.map(automation => { const issues = validateAutomation(automation, { scenes, sources }); return ( @@ -158,9 +259,9 @@ export default function EditAutomations() { {automation.conditions.map((c, i) => { const game = conditionGame(c); return game ? ( - + {game} - + ) : null; })} diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less new file mode 100644 index 000000000000..1b4148b04698 --- /dev/null +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less @@ -0,0 +1,142 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0 8px; +} + +.gameIcon { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--icon-active); + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: #000; + margin-bottom: 12px; +} + +.title { + margin: 0 0 20px; + font-size: 20px; + font-weight: 700; + color: var(--title); + text-align: center; +} + +.featured { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + margin-bottom: 16px; +} + +.navBtn { + background: none; + border: none; + color: var(--paragraph); + font-size: 20px; + cursor: pointer; + padding: 8px; + flex-shrink: 0; + border-radius: 4px; + + &:hover { + color: var(--title); + background: var(--section); + } +} + +.card { + flex: 1; + min-width: 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border); +} + +.preview { + width: 100%; + height: 200px; + display: flex; + align-items: center; + justify-content: center; +} + +.previewLabel { + font-size: 16px; + font-weight: 700; + color: rgba(255, 255, 255, 0.5); + letter-spacing: 2px; + text-transform: uppercase; +} + +.infoRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--section); + gap: 12px; +} + +.itemTitle { + font-size: 15px; + font-weight: 700; + color: var(--title); + margin-bottom: 2px; +} + +.itemDesc { + font-size: 12px; + color: var(--paragraph); +} + +.thumbnailStrip { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.thumbnail { + width: 72px; + height: 48px; + border-radius: 4px; + border: 2px solid transparent; + cursor: pointer; + padding: 0; + flex-shrink: 0; + + &:hover { + border-color: var(--icon-active); + } +} + +.thumbnailActive { + border-color: var(--icon-active); +} + +.dots { + display: flex; + gap: 6px; + align-items: center; +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--border); + cursor: pointer; + + &:hover { + background: var(--paragraph); + } +} + +.dotActive { + background: var(--icon-active); +} diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx new file mode 100644 index 000000000000..7548479eea13 --- /dev/null +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react'; +import { Button, Switch } from 'antd'; +import { ModalLayout } from 'components-react/shared/ModalLayout'; +import { Services } from 'components-react/service-provider'; +import { $t } from 'services/i18n'; +import type { ConditionType } from 'services/stream-avatar/engine/conditions'; +import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; +import styles from './PreMadeAutomations.m.less'; + +interface PreMadeItem { + title: string; + description: string; + gameName: string; + color: string; + automation: Omit; +} + +// ponytail: static seed data — replace with API fetch when catalog exists +const PRE_MADE: PreMadeItem[] = [ + { + title: 'Victory Royale', + description: 'Co-host comments on each win.', + gameName: 'Fortnite', + color: '#1a3a5c', + automation: { + description: 'Victory Royale', + conditions: [{ type: 'fortnite.victory_royale' as ConditionType }], + actions: [{ type: 'co-host.comment' }], + enabled: true, + }, + }, + { + title: 'Enemy Knocked', + description: 'Co-host reacts when you knock an enemy.', + gameName: 'Fortnite', + color: '#2d4a1e', + automation: { + description: 'Enemy Knocked', + conditions: [{ type: 'fortnite.knocked' as ConditionType }], + actions: [{ type: 'co-host.comment' }], + enabled: true, + }, + }, + { + title: 'Defeat', + description: 'Co-host reacts to each defeat.', + gameName: 'Fortnite', + color: '#3a1a1a', + automation: { + description: 'Defeat', + conditions: [{ type: 'fortnite.defeat' as ConditionType }], + actions: [{ type: 'co-host.comment' }], + enabled: true, + }, + }, + { + title: 'Round Won', + description: 'Co-host comments on every round win.', + gameName: 'Valorant', + color: '#2a1a3a', + automation: { + description: 'Round Won', + conditions: [{ type: 'valorant.victory' as ConditionType }], + actions: [{ type: 'co-host.comment' }], + enabled: true, + }, + }, + { + title: 'Player Eliminated', + description: 'Co-host reacts to each elimination.', + gameName: 'Valorant', + color: '#1a1a3a', + automation: { + description: 'Player Eliminated', + conditions: [{ type: 'valorant.player_eliminated' as ConditionType }], + actions: [{ type: 'co-host.comment' }], + enabled: true, + }, + }, +]; + +interface Props { + onClose: () => void; +} + +export default function PreMadeAutomations({ onClose }: Props) { + const { AutomationsService } = Services; + const [currentIndex, setCurrentIndex] = useState(0); + const [enabledSet, setEnabledSet] = useState>(new Set()); + const [saving, setSaving] = useState(false); + + function toggleItem(index: number) { + setEnabledSet(prev => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + } + + function goTo(index: number) { + setCurrentIndex(index); + } + + function prev() { + setCurrentIndex(i => (i > 0 ? i - 1 : PRE_MADE.length - 1)); + } + + function next() { + setCurrentIndex(i => (i < PRE_MADE.length - 1 ? i + 1 : 0)); + } + + async function handleComplete() { + setSaving(true); + try { + for (const index of enabledSet) { + await AutomationsService.actions.create(PRE_MADE[index].automation); + } + onClose(); + } finally { + setSaving(false); + } + } + + const current = PRE_MADE[currentIndex]; + + const footer = ( + <> + + + + ); + + return ( + +
+
+ +
+ +

{$t('Select Pre-made Automation')}

+ +
+ + +
+
+ {current.gameName} +
+
+
+
{current.title}
+
{current.description}
+
+ toggleItem(currentIndex)} + /> +
+
+ + +
+ +
+ {PRE_MADE.map((item, i) => ( +
+ +
+ {PRE_MADE.map((_, i) => ( + goTo(i)} + /> + ))} +
+
+
+ ); +} diff --git a/app/i18n/en-US/stream-avatar-automations.json b/app/i18n/en-US/stream-avatar-automations.json index a355c2f5278c..78b209348dad 100644 --- a/app/i18n/en-US/stream-avatar-automations.json +++ b/app/i18n/en-US/stream-avatar-automations.json @@ -1,10 +1,39 @@ { + "Automations": "Automations", + "Automatically trigger stream effects in response to gameplay events": "Automatically trigger stream effects in response to gameplay events", + "Automatically trigger on stream effects in response to gameplay events.": "Automatically trigger on stream effects in response to gameplay events.", + "Filter by": "Filter by", + "All game automations": "All game automations", + "Add New": "Add New", + "Add New Automation": "Add New Automation", + "Select from Pre-made": "Select from Pre-made", + "Select Pre-made Automation": "Select Pre-made Automation", + "DESCRIPTION": "DESCRIPTION", + "TRIGGER": "TRIGGER", + "REACTION": "REACTION", + "GAME": "GAME", + "No automations match the selected filter.": "No automations match the selected filter.", + "You don't have Automations set up yet.": "You don't have Automations set up yet.", + "Automatically trigger on stream effects including visual effects, transitions, sounds, agent commentary (and more) in response to gameplay events such as kills, wins, knocks, deaths, and much more. See supported games.": "Automatically trigger on stream effects including visual effects, transitions, sounds, agent commentary (and more) in response to gameplay events such as kills, wins, knocks, deaths, and much more. See supported games.", + "Create Custom": "Create Custom", + "Use Pre-made Automations": "Use Pre-made Automations", + "Adding...": "Adding...", + "View Automation Templates": "View Automation Templates", + "Add Trigger": "Add Trigger", + "Set the condition that activates this automation": "Set the condition that activates this automation", + "Select a Game": "Select a Game", + "Select a Condition": "Select a Condition", + "Add Reaction(s)": "Add Reaction(s)", + "Add action(s) to perform after the trigger": "Add action(s) to perform after the trigger", + "Add Reaction": "Add Reaction", + "Select an Action...": "Select an Action...", + "Remove reaction": "Remove reaction", "Edit Automations.": "Edit Automations.", - "New Automation": "New Automation", "Edit Automation": "Edit Automation", "Create Automation": "Create Automation", + "Save Automation": "Save Automation", + "Saving...": "Saving...", "Delete this automation?": "Delete this automation?", - "You don't have any automations yet.": "You don't have any automations yet.", "Loading...": "Loading...", "Test automation": "Test automation", "Enabled": "Enabled", @@ -12,20 +41,8 @@ "(unknown)": "(unknown)", "(no description)": "(no description)", "Description": "Description", - "When": "When", - "Do": "Do", - "Game": "Game", "Edit": "Edit", "Delete": "Delete", - "Back": "Back", - "Saving...": "Saving...", - "Save Changes": "Save Changes", - "When (Condition)": "When (Condition)", - "Do (Actions)": "Do (Actions)", - "No conditions available": "No conditions available", - "+ Add Action": "+ Add Action", - "Insert a new action after this one": "Insert a new action after this one", - "Remove this action": "Remove this action", "Drag to reorder": "Drag to reorder", "seconds": "seconds", "e.g. Victory Royale reaction": "e.g. Victory Royale reaction", From 28463c96eb83a25e52cb952bb884547e7369db5b Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 24 Jun 2026 13:59:34 -0700 Subject: [PATCH 45/79] feat(automations): update styles and functionality for automation components --- .../AutomationEditor.m.less | 10 + .../AutomationEditor.tsx | 15 +- .../EditAutomations.m.less | 6 +- .../PreMadeAutomations.m.less | 62 +++--- .../PreMadeAutomations.tsx | 187 ++++++++++++------ .../stream-avatar/automations-service.ts | 6 +- 6 files changed, 183 insertions(+), 103 deletions(-) create mode 100644 app/components-react/windows/stream-avatar-automations/AutomationEditor.m.less diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.m.less b/app/components-react/windows/stream-avatar-automations/AutomationEditor.m.less new file mode 100644 index 000000000000..bb729135e05e --- /dev/null +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.m.less @@ -0,0 +1,10 @@ +@import '../../../styles/colors'; + +.trashIcon { + color: @light-5; + cursor: pointer; + + &:hover { + color: var(--title); + } +} diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index 121dfd692a95..1c0c00aee15a 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -20,6 +20,7 @@ import type { ExportedActionProps, } from 'services/stream-avatar/engine/actions'; import { TextInput, CheckboxInput, SliderInput } from 'components-react/shared/inputs'; +import styles from './AutomationEditor.m.less'; const errorTextStyle: CSSProperties = { margin: '4px 0 0', @@ -54,8 +55,6 @@ const trashCellStyle: CSSProperties = { display: 'flex', alignItems: 'center', height: CONTROL_HEIGHT, - cursor: 'pointer', - color: 'var(--icon-active)', fontSize: '14px', }; @@ -241,7 +240,7 @@ function ActionEditor({ gridTemplateColumns: 'auto 1.2fr minmax(0, 1fr) auto', gap: '12px', alignItems: 'start', - padding: '12px 0', + padding: '8px 0', borderBottom: '1px solid var(--border)', }} > @@ -264,7 +263,12 @@ function ActionEditor({ {renderControl()} -
onRemove(index)} title={$t('Remove reaction')}> +
onRemove(index)} + title={$t('Remove reaction')} + >
@@ -501,7 +505,6 @@ export default function AutomationEditor({ initial, onClose, onViewTemplates }: onChange={val => setSelectedGame(val)} placeholder={$t('Select a Game')} options={GAME_OPTIONS} - dropdownMatchSelectWidth={false} /> `s are sorted **at the point of display** rather than by +reordering the source registries, so new games/conditions sort automatically: + +- `GAME_OPTIONS` (`AutomationEditor.tsx`) → `.sort((a, b) => a.name.localeCompare(b.name))` + (by display name, e.g. Apex Legends, Battlefield 6, Call of Duty: Black Ops 6, …). +- `getConditionOptions(gameId)` → `.sort((a, b) => a.label.localeCompare(b.label))` + (by condition label, within the selected game). + +`localeCompare` gives human-alphabetical order. The default-selected condition is +now `conditionOptions[0]` (alphabetically first). `GAME_NAMES` and the per-game +condition files keep their authoring order — ordering is purely a render concern. + +## 6. Internationalisation (i18n) + +Desktop's `$t` is VueI18n. **en-US is bundled** from `app/i18n/fallback.ts`, NOT +read from the `en-US/` folder at runtime — so a new dictionary file only takes +effect once it's `require`d in `fallback.ts`. VueI18n only interpolates `%{}` for +keys present in the dictionary; new unregistered strings render the placeholder +literally (this was an earlier visible bug with `%{name}`). + +Done: +- Added **`app/i18n/en-US/stream-avatar-automations.json`** — all 47 user-facing + strings of the feature (launcher, list, editor, validation), with `%{name}` / + `%{max}` placeholders preserved. +- Registered it in **`app/i18n/fallback.ts`**. + +Effect: `%{}` now interpolates natively for en-US, and since `en-US` is the +`fallbackLocale`, other locales inherit the interpolatable string before they're +translated. The manual `t()` helper in `validation.ts` is now redundant but left +in as harmless defensive code. + +--- + +## 7. Files touched (quick index) + +**Created** +- `app/services/stream-avatar/engine/conditions.ts` — flat merge of `conditions/` directory (see §5c round 2). +- `app/services/stream-avatar/engine/instructions.ts` — flat merge of `instructions/` directory (see §5c round 2). +- `app/services/stream-avatar/engine/validation.ts` +- `app/i18n/en-US/stream-avatar-automations.json` +- `AUTOMATIONS_MIGRATION.md` (this file) + +**Deleted** +- `app/services/stream-avatar/engine/conditions/` (entire directory — 27 files including `index.ts`, `shared.ts`, and 25 per-game files). +- `app/services/stream-avatar/engine/instructions/` (entire directory — 26 files including `index.ts` and 25 per-game files). + +**Edited** +- `app/services/stream-avatar/engine/actions.ts` — `defaultExportedProps`, `withActionDefaults`, removed debug log, `as never` fix. +- `app/services/stream-avatar/automations-engine-service.ts` — `simulateAutomation(id)`. +- `app/components-react/windows/stream-avatar-automations/EditAutomations.tsx` — validation error icon, play/Test button + `Spin` + `simulatingId`, live scenes/sources. +- `app/components-react/windows/stream-avatar-automations/EditAutomations.m.less` — `.errorIcon`, `.disabledIcon`. +- `app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx` — shared validation, inline errors/red borders, removed Enabled control, `withActionDefaults` wiring, **drag-and-drop reorder + insert-between** (`react-sortablejs`, `ActionRow` stable ids), **alphabetical game/condition sorting** (see §5d). +- `app/i18n/fallback.ts` — registered the automations dictionary. + +--- + +## 8. Known gaps / next steps + +1. **Registry labels not translatable.** Action/condition/game labels come from + `ActionRegistry`, `Conditions`, `GAME_NAMES` and are rendered directly (not via + `$t`), so they stay English regardless of locale. Wrapping them is a larger + pass: wrap at each render point (`ACTION_OPTIONS` / `summarizeActions` / + `conditionLabel` / `GAME_OPTIONS`) and add the label strings to the dictionary. +2. **Full CLI build not yet run clean.** The shell safety classifier was + intermittently unavailable, so `tsc` / `yarn compile` wasn't confirmed end-to-end. + Run `npx tsc --noEmit` from `desktop/` (or `yarn compile` in CI) to confirm the + §5c round-2 flat-file consolidation is clean. The `satisfies Record` + on `perGameInstructions` provides a compile-time guard that every condition key + has a matching instruction. +3. **Diagnostic `console.log`s** remain in `automations-service.ts` (fetchAll raw + response logging — see `.claude/plans/immutable-prancing-pancake.md`), + `agent-socket-service.ts`, and backend controllers/repos. Strip once the socket + path is confirmed working. `console.warn` lines in the engine are intentional. +4. **Non-destructive simulation** mode (optional) — see §4 note. + +--- + +## 9. How to resume + +1. Open the list modal via the **Edit Automations.** button in `SceneSelector`. +2. Verify: validation icons appear for automations referencing deleted + scenes/sources; the editor blocks invalid saves and shows inline errors; the + play button runs + reverts an automation with an accurate spinner; a + default-position `wait_for_ms` saves `duration: 5000`. +3. Pick up from §8 — most likely the registry-label i18n pass and a clean + `yarn compile`, then the `console.log` cleanup once the backend round-trip is + confirmed. From 4e8368ba4dbcc6112bd2210082963cabfc672056 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 8 Jul 2026 09:51:59 -0700 Subject: [PATCH 51/79] feat(automations): integrate Intelligent Streaming Agent app detection and installation prompts in automation editor --- .../hooks/useAgentAppInstalled.ts | 61 ++++++++ app/components-react/pages/AILanding.tsx | 35 +---- .../AutomationEditor.tsx | 146 +++++++++++++++++- .../EditAutomations.tsx | 8 +- app/services/hosts.ts | 4 +- .../stream-avatar/engine/validation.ts | 16 ++ 6 files changed, 228 insertions(+), 42 deletions(-) create mode 100644 app/components-react/hooks/useAgentAppInstalled.ts diff --git a/app/components-react/hooks/useAgentAppInstalled.ts b/app/components-react/hooks/useAgentAppInstalled.ts new file mode 100644 index 000000000000..df6afe9a4fd3 --- /dev/null +++ b/app/components-react/hooks/useAgentAppInstalled.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { Services } from 'components-react/service-provider'; +import { EMenuItemKey } from 'services/side-nav'; +import { getOS, OS } from 'util/operating-systems'; + +export const AGENT_APP_STORE_ID = '7643'; +export const AGENT_APP_ID = '93125d1c33'; + +/** + * Tracks whether the Intelligent Streaming Agent (co-host) platform app is + * installed and enabled, and exposes helpers to install or (re-)enable it — + * the same detection/redirect logic AILanding.tsx uses for its co-host + * feature card. Installed and enabled are tracked separately since a user + * can install the app but later disable it from Settings > Installed Apps. + */ +export function useAgentAppInstalled() { + const { NavigationService, PlatformAppsService, SideNavService } = Services; + const [isInstalled, setIsInstalled] = useState(false); + const [isEnabled, setIsEnabled] = useState(false); + + useEffect(() => { + let active = true; + let processing = false; + + void loadProductionApps(); + + return () => { + active = false; + }; + + async function loadProductionApps() { + if (getOS() !== OS.Windows) return; + if (processing) return; + + processing = true; + setIsInstalled(false); + setIsEnabled(false); + await PlatformAppsService.actions.return.loadProductionApps(); + + if (!active) return; + + const app = PlatformAppsService.views.productionApps.find(a => a.id === AGENT_APP_ID); + setIsInstalled(!!app); + setIsEnabled(!!app?.enabled); + processing = false; + } + }, []); + + async function installAgent() { + await PlatformAppsService.actions.return.refreshProductionApps(); + NavigationService.actions.navigate('PlatformAppStore', { appId: AGENT_APP_STORE_ID }); + SideNavService.actions.setCurrentMenuItem(EMenuItemKey.AppStore); + } + + function enableAgent() { + PlatformAppsService.actions.setEnabled(AGENT_APP_ID, true); + setIsEnabled(true); + } + + return { isInstalled, isEnabled, installAgent, enableAgent }; +} diff --git a/app/components-react/pages/AILanding.tsx b/app/components-react/pages/AILanding.tsx index 5aa58ac9bb28..14c6069929da 100644 --- a/app/components-react/pages/AILanding.tsx +++ b/app/components-react/pages/AILanding.tsx @@ -1,5 +1,6 @@ import cx from 'classnames'; import { useVuex } from 'components-react/hooks'; +import { AGENT_APP_ID, useAgentAppInstalled } from 'components-react/hooks/useAgentAppInstalled'; import { useRealmObject } from 'components-react/hooks/realm'; import { Services } from 'components-react/service-provider'; import { SwitchInput } from 'components-react/shared/inputs'; @@ -14,7 +15,6 @@ import { IOverlayCollectionParams, TOverlayType } from 'services/user'; import { $i } from 'services/utils'; import { WidgetDisplayData } from 'services/widgets'; import { WidgetType } from 'services/widgets/widgets-data'; -import { getOS, OS } from 'util/operating-systems'; import styles from './AILanding.m.less'; interface FeatureAction { @@ -32,9 +32,6 @@ interface FeatureProps { actions?: FeatureAction | FeatureAction[]; } -const AGENT_APP_STORE_ID = '7643'; -const AGENT_APP_ID = '93125d1c33'; - function AIFeature(props: FeatureProps) { const actionsObj = props.actions ?? []; const actions = Array.isArray(actionsObj) ? actionsObj : [actionsObj]; @@ -103,33 +100,9 @@ export default function AILanding() { [sources], ); - const [isAgentAppInstalled, setIsAgentAppInstalled] = useState(false); + const { isInstalled: isAgentAppInstalled, installAgent } = useAgentAppInstalled(); useEffect(() => { - let active = true; - let processing = false; - - void loadProductionApps(); trackEvent('impression'); - - return () => { - active = false; - }; - - async function loadProductionApps() { - if (getOS() !== OS.Windows) return; - if (processing) return; - - processing = true; - setIsAgentAppInstalled(false); - await PlatformAppsService.actions.return.loadProductionApps(); - - // Bail early if the component unmounted while we were loading. - if (!active) return; - - const installed = PlatformAppsService.views.productionApps.some(a => a.id === AGENT_APP_ID); - setIsAgentAppInstalled(installed); - processing = false; - } }, []); function onToggleAiClick(isEnabled?: boolean) { @@ -190,9 +163,7 @@ export default function AILanding() { async function onInstallAgentClick() { trackEvent('install-agent'); - await PlatformAppsService.actions.return.refreshProductionApps(); - NavigationService.actions.navigate('PlatformAppStore', { appId: AGENT_APP_STORE_ID }); - SideNavService.actions.setCurrentMenuItem(EMenuItemKey.AppStore); + await installAgent(); } async function onLaunchAgentClick() { diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index b1c1a16f1917..9fbbc77dd1c1 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -1,10 +1,12 @@ import React, { CSSProperties, useEffect, useState } from 'react'; -import { Button, Select, Input, Slider } from 'antd'; +import { Button, Select, Input, Slider, Tag } from 'antd'; import { Properties } from 'services/stream-avatar/engine/properties'; import { ReactSortable } from 'react-sortablejs'; import uuid from 'uuid/v4'; import { ModalLayout } from 'components-react/shared/ModalLayout'; +import { alertAsync } from 'components-react/modals'; import { useVuex } from 'components-react/hooks'; +import { useAgentAppInstalled } from 'components-react/hooks/useAgentAppInstalled'; import { Services } from 'components-react/service-provider'; import { $t } from 'services/i18n'; import { Conditions, GAME_NAMES, ConditionType } from 'services/stream-avatar/engine/conditions'; @@ -58,10 +60,25 @@ const trashCellStyle: CSSProperties = { fontSize: '14px', }; -const ACTION_OPTIONS = Object.entries(ActionRegistry).map(([type, def]) => ({ - label: def.label, - value: type as ActionType, -})); +function requiresAgentApp(type: ActionType): boolean { + return ActionRegistry[type]?.group === 'co-host'; +} + +function getActionOptions() { + return Object.entries(ActionRegistry).map(([type, def]) => ({ + label: requiresAgentApp(type as ActionType) ? ( + + {def.label}{' '} + + {$t('Requires ISA App')} + + + ) : ( + def.label + ), + value: type as ActionType, + })); +} const GAME_OPTIONS = Object.entries(GAME_NAMES) .map(([id, name]) => ({ label: name, value: id })) @@ -80,6 +97,10 @@ interface ActionEditorProps { scenes: { id: string; name: string }[]; sources: { id: string; name: string }[]; errors?: Record; + isAgentInstalled: boolean; + isAgentEnabled: boolean; + onInstallAgent: () => Promise; + onEnableAgent: () => void; onChange: (index: number, action: ExportedAction) => void; onRemove: (index: number) => void; } @@ -90,10 +111,65 @@ function ActionEditor({ scenes, sources, errors, + isAgentInstalled, + isAgentEnabled, + onInstallAgent, + onEnableAgent, onChange, onRemove, }: ActionEditorProps) { + const agentReady = isAgentInstalled && isAgentEnabled; + + async function requireAgentApp() { + if (!isAgentInstalled) { + await alertAsync({ + type: 'confirm', + title: $t('Intelligent Streaming Agent Required'), + closable: true, + content: ( + + {$t( + 'Co-host actions require the Intelligent Streaming Agent app. Install it to use this action.', + )} + + ), + cancelText: $t('Cancel'), + okText: $t('Install'), + okButtonProps: { type: 'primary' }, + onOk: () => { + void onInstallAgent(); + }, + cancelButtonProps: { style: { display: 'inline' } }, + }); + return; + } + + await alertAsync({ + type: 'confirm', + title: $t('Intelligent Streaming Agent Disabled'), + closable: true, + content: ( + + {$t( + 'The Intelligent Streaming Agent app is installed but currently disabled. Enable it to use this action.', + )} + + ), + cancelText: $t('Cancel'), + okText: $t('Enable'), + okButtonProps: { type: 'primary' }, + onOk: () => { + onEnableAgent(); + }, + cancelButtonProps: { style: { display: 'inline' } }, + }); + } + function setType(type: ActionType) { + if (requiresAgentApp(type) && !agentReady) { + void requireAgentApp(); + return; + } onChange(index, withActionDefaults({ type })); } @@ -104,6 +180,8 @@ function ActionEditor({ }); } + const actionOptions = getActionOptions(); + const props = (action.props || {}) as ExportedActionProps; const sceneName = props.scene?.name ?? ''; @@ -111,6 +189,44 @@ function ActionEditor({ const sourceName = props.source?.name ?? ''; const sourceMissing = !!sourceName && !sources.some(s => s.name === sourceName); + function renderAgentRequiredNotice() { + if (!isAgentInstalled) { + return ( +

+ {$t('Requires the Intelligent Streaming Agent app.')} + void onInstallAgent()}>{$t('Install')} +

+ ); + } + + return ( +

+ {$t('The Intelligent Streaming Agent app is disabled.')} + onEnableAgent()}>{$t('Enable')} +

+ ); + } + function renderControl() { switch (action.type) { case 'common.switch_to_scene': { @@ -200,6 +316,7 @@ function ActionEditor({ ); case 'co-host.instruction': + if (!agentReady) return renderAgentRequiredNotice(); return ( <> setType(val as ActionType)} placeholder={$t('Select an Action...')} - options={ACTION_OPTIONS} + options={actionOptions} />
@@ -283,6 +401,12 @@ interface Props { export default function AutomationEditor({ initial, onClose, onViewTemplates }: Props) { const { AutomationsService, ScenesService, SourcesService } = Services; + const { + isInstalled: isAgentInstalled, + isEnabled: isAgentEnabled, + installAgent, + enableAgent, + } = useAgentAppInstalled(); const { scenes, sources } = useVuex(() => ({ scenes: ScenesService.views.scenes.map(s => ({ id: s.id, name: s.name })), @@ -327,7 +451,11 @@ export default function AutomationEditor({ initial, onClose, onViewTemplates }: actions, enabled, }; - const issues = validateAutomation(draft, { scenes, sources }); + const issues = validateAutomation(draft, { + scenes, + sources, + agentAppReady: isAgentInstalled && isAgentEnabled, + }); const descriptionError = attempted ? issues.find(i => i.scope === 'description')?.message @@ -579,6 +707,10 @@ export default function AutomationEditor({ initial, onClose, onViewTemplates }: scenes={scenes} sources={sources} errors={actionErrors[i]} + isAgentInstalled={isAgentInstalled} + isAgentEnabled={isAgentEnabled} + onInstallAgent={installAgent} + onEnableAgent={enableAgent} onChange={handleActionChange} onRemove={handleActionRemove} /> diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index 9950b4243146..2c50760f9b9a 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { Button, Switch, Tooltip, Spin, Popconfirm, Select, Dropdown, Menu, Tag } from 'antd'; import { ModalLayout } from 'components-react/shared/ModalLayout'; import { useVuex } from 'components-react/hooks'; +import { useAgentAppInstalled } from 'components-react/hooks/useAgentAppInstalled'; import { Services } from 'components-react/service-provider'; import { $t } from 'services/i18n'; import { Conditions, GAME_NAMES } from 'services/stream-avatar/engine/conditions'; @@ -47,6 +48,7 @@ export default function EditAutomations() { scenes: ScenesService.views.scenes.map(s => ({ id: s.id, name: s.name })), sources: SourcesService.views.sources.map(s => ({ id: s.sourceId, name: s.name })), })); + const { isInstalled: isAgentInstalled, isEnabled: isAgentEnabled } = useAgentAppInstalled(); const [editingAutomation, setEditingAutomation] = useState(null); const [creating, setCreating] = useState(false); @@ -256,7 +258,11 @@ export default function EditAutomations() {
{filtered.map(automation => { - const issues = validateAutomation(automation, { scenes, sources }); + const issues = validateAutomation(automation, { + scenes, + sources, + agentAppReady: isAgentInstalled && isAgentEnabled, + }); return (
{$t('Description')}{$t('When')}{$t('Do')}{$t('Game')}{$t('DESCRIPTION')}{$t('TRIGGER')}{$t('REACTION')}{$t('GAME')}
diff --git a/app/services/hosts.ts b/app/services/hosts.ts index 20ff7ea63f2d..9314e5affc74 100644 --- a/app/services/hosts.ts +++ b/app/services/hosts.ts @@ -63,8 +63,8 @@ export class HostsService extends Service { // return 'ai-agent.streamlabs.com'; // } - //return 'ai-agent.streamlabs.com'; - return 'localhost:3000'; + return 'ai-agent.streamlabs.com'; + //return 'localhost:3000'; } } diff --git a/app/services/stream-avatar/engine/validation.ts b/app/services/stream-avatar/engine/validation.ts index 02cd19418cad..97d089217c05 100644 --- a/app/services/stream-avatar/engine/validation.ts +++ b/app/services/stream-avatar/engine/validation.ts @@ -1,5 +1,6 @@ import { $t } from 'services/i18n'; import { Conditions } from './conditions'; +import { ActionRegistry } from './actions'; import type { TAutomationExport } from './automations'; import type { ExportedAction, ExportedActionProps } from './actions'; @@ -13,6 +14,8 @@ export interface IResourceRef { export interface IAvailableResources { scenes: IResourceRef[]; sources: IResourceRef[]; + /** Whether the Intelligent Streaming Agent app is installed AND enabled, gating co-host actions. */ + agentAppReady?: boolean; } /** Where an issue applies, so the editor can render it next to the right field. */ @@ -58,6 +61,19 @@ function validateAction( message, }); + if ( + action?.type && + ActionRegistry[action.type]?.group === 'co-host' && + resources.agentAppReady === false + ) { + issues.push( + actionIssue( + 'type', + $t('Requires the Intelligent Streaming Agent app to be installed and enabled.'), + ), + ); + } + switch (action?.type) { case 'common.switch_to_scene': { const name = props.scene?.name?.trim(); From acc19277ae03e99d6bcd47c3d737f38e92f2bd93 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 8 Jul 2026 10:29:52 -0700 Subject: [PATCH 52/79] refactor(automations): streamline agent app detection logic and rename notification function --- .../hooks/useAgentAppInstalled.ts | 44 +++++++------------ .../AutomationEditor.tsx | 9 ++-- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/app/components-react/hooks/useAgentAppInstalled.ts b/app/components-react/hooks/useAgentAppInstalled.ts index df6afe9a4fd3..9d4419ab8766 100644 --- a/app/components-react/hooks/useAgentAppInstalled.ts +++ b/app/components-react/hooks/useAgentAppInstalled.ts @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; +import { useVuex } from 'components-react/hooks'; import { Services } from 'components-react/service-provider'; import { EMenuItemKey } from 'services/side-nav'; import { getOS, OS } from 'util/operating-systems'; @@ -12,40 +13,26 @@ export const AGENT_APP_ID = '93125d1c33'; * the same detection/redirect logic AILanding.tsx uses for its co-host * feature card. Installed and enabled are tracked separately since a user * can install the app but later disable it from Settings > Installed Apps. + * + * Reads reactively off PlatformAppsService's Vuex state (rather than local + * component state) so every consumer of this hook — e.g. the automations + * list and its edit modal — stays in sync the instant the app is installed + * or enabled anywhere, without needing to remount. */ export function useAgentAppInstalled() { const { NavigationService, PlatformAppsService, SideNavService } = Services; - const [isInstalled, setIsInstalled] = useState(false); - const [isEnabled, setIsEnabled] = useState(false); useEffect(() => { - let active = true; - let processing = false; - - void loadProductionApps(); - - return () => { - active = false; - }; - - async function loadProductionApps() { - if (getOS() !== OS.Windows) return; - if (processing) return; - - processing = true; - setIsInstalled(false); - setIsEnabled(false); - await PlatformAppsService.actions.return.loadProductionApps(); - - if (!active) return; - - const app = PlatformAppsService.views.productionApps.find(a => a.id === AGENT_APP_ID); - setIsInstalled(!!app); - setIsEnabled(!!app?.enabled); - processing = false; - } + if (getOS() !== OS.Windows) return; + void PlatformAppsService.actions.return.loadProductionApps(); }, []); + const { isInstalled, isEnabled } = useVuex(() => { + if (getOS() !== OS.Windows) return { isInstalled: false, isEnabled: false }; + const app = PlatformAppsService.views.productionApps.find(a => a.id === AGENT_APP_ID); + return { isInstalled: !!app, isEnabled: !!app?.enabled }; + }); + async function installAgent() { await PlatformAppsService.actions.return.refreshProductionApps(); NavigationService.actions.navigate('PlatformAppStore', { appId: AGENT_APP_STORE_ID }); @@ -54,7 +41,6 @@ export function useAgentAppInstalled() { function enableAgent() { PlatformAppsService.actions.setEnabled(AGENT_APP_ID, true); - setIsEnabled(true); } return { isInstalled, isEnabled, installAgent, enableAgent }; diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index 9fbbc77dd1c1..1759d6662971 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -120,7 +120,7 @@ function ActionEditor({ }: ActionEditorProps) { const agentReady = isAgentInstalled && isAgentEnabled; - async function requireAgentApp() { + async function notifyAgentRequired() { if (!isAgentInstalled) { await alertAsync({ type: 'confirm', @@ -166,11 +166,12 @@ function ActionEditor({ } function setType(type: ActionType) { + onChange(index, withActionDefaults({ type })); + // Always apply the selection — this is informational, not a gate, so it + // can't leave the Select showing a value that was never actually set. if (requiresAgentApp(type) && !agentReady) { - void requireAgentApp(); - return; + void notifyAgentRequired(); } - onChange(index, withActionDefaults({ type })); } function setProp(key: string, value: unknown) { From 82014a01fc38f633404357bc3d125a1091271fc3 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 8 Jul 2026 10:31:00 -0700 Subject: [PATCH 53/79] feat(automations): add ISA app requirements and status messages to automation prompts --- app/i18n/en-US/stream-avatar-automations.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/i18n/en-US/stream-avatar-automations.json b/app/i18n/en-US/stream-avatar-automations.json index 78b209348dad..c5ead1874b84 100644 --- a/app/i18n/en-US/stream-avatar-automations.json +++ b/app/i18n/en-US/stream-avatar-automations.json @@ -54,6 +54,16 @@ "Hide if condition is false": "Hide if condition is false", "Show if condition is false": "Show if condition is false", "The co-host will automatically comment based on the active game condition.": "The co-host will automatically comment based on the active game condition.", + "Requires ISA App": "Requires ISA App", + "Intelligent Streaming Agent Required": "Intelligent Streaming Agent Required", + "Co-host actions require the Intelligent Streaming Agent app. Install it to use this action.": "Co-host actions require the Intelligent Streaming Agent app. Install it to use this action.", + "Intelligent Streaming Agent Disabled": "Intelligent Streaming Agent Disabled", + "The Intelligent Streaming Agent app is installed but currently disabled. Enable it to use this action.": "The Intelligent Streaming Agent app is installed but currently disabled. Enable it to use this action.", + "Requires the Intelligent Streaming Agent app.": "Requires the Intelligent Streaming Agent app.", + "The Intelligent Streaming Agent app is disabled.": "The Intelligent Streaming Agent app is disabled.", + "Install": "Install", + "Enable": "Enable", + "Cancel": "Cancel", "Please fix the highlighted fields before saving.": "Please fix the highlighted fields before saving.", "Failed to save automation.": "Failed to save automation.", "Add a description.": "Add a description.", @@ -66,5 +76,6 @@ "Select a source.": "Select a source.", "Source \"%{name}\" is unavailable.": "Source \"%{name}\" is unavailable.", "Enter an instruction for the co-host.": "Enter an instruction for the co-host.", - "Instruction must be %{max} characters or fewer.": "Instruction must be %{max} characters or fewer." + "Instruction must be %{max} characters or fewer.": "Instruction must be %{max} characters or fewer.", + "Requires the Intelligent Streaming Agent app to be installed and enabled.": "Requires the Intelligent Streaming Agent app to be installed and enabled." } From e1d015f5cf8c22f6491c435a644ebb1306e4132c Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 8 Jul 2026 11:20:03 -0700 Subject: [PATCH 54/79] feat(automations): enhance error handling and retry logic in automation services and components --- .../editor/elements/AutomationsElement.tsx | 37 ++++++- .../editor/elements/SceneSelector.tsx | 42 ++++--- .../EditAutomations.tsx | 38 +++++-- app/i18n/en-US/stream-avatar-automations.json | 3 + .../stream-avatar/agent-socket-service.ts | 104 +++++++++++++++++- .../stream-avatar/automations-service.ts | 47 ++++++++ 6 files changed, 238 insertions(+), 33 deletions(-) diff --git a/app/components-react/editor/elements/AutomationsElement.tsx b/app/components-react/editor/elements/AutomationsElement.tsx index 6e9394436517..467f8c1730c3 100644 --- a/app/components-react/editor/elements/AutomationsElement.tsx +++ b/app/components-react/editor/elements/AutomationsElement.tsx @@ -19,17 +19,20 @@ function conditionLabel(automation: TAutomationExport) { } function AutomationsContent() { - const { AutomationsService, AutomationsEngineService } = Services; - const { automations, loading } = useVuex(() => ({ + const { AutomationsService, AutomationsEngineService, AgentSocketService, UserService } = Services; + const { automations, loaded, error, isLoggedIn } = useVuex(() => ({ automations: AutomationsService.state.automations, - loading: AutomationsService.state.loading, + loaded: AutomationsService.state.loaded, + error: AutomationsService.state.error, + isLoggedIn: UserService.views.isLoggedIn, })); const [simulatingId, setSimulatingId] = useState(null); useEffect(() => { + if (!isLoggedIn) return; AutomationsService.actions.fetchAll(); - }, []); + }, [isLoggedIn]); async function simulate(e: React.MouseEvent, id: number) { e.stopPropagation(); @@ -54,7 +57,31 @@ function AutomationsContent() { AutomationsService.actions.showCreateAutomation(); } - if (loading) { + function retryNow() { + AgentSocketService.actions.reconnect(); + AutomationsService.actions.fetchAll(); + } + + if (!isLoggedIn) { + return ( +
+ {$t('Log in to use Automations.')} +
+ ); + } + + if (error) { + return ( +
+ {$t('Unable to reach the automations server. Retrying…')} + + + +
+ ); + } + + if (!loaded) { return (
diff --git a/app/components-react/editor/elements/SceneSelector.tsx b/app/components-react/editor/elements/SceneSelector.tsx index ab36ebd683dd..4449fb903e7a 100644 --- a/app/components-react/editor/elements/SceneSelector.tsx +++ b/app/components-react/editor/elements/SceneSelector.tsx @@ -26,23 +26,27 @@ function SceneSelector() { ProjectorService, EditorCommandsService, AutomationsService, + UserService, } = Services; const { treeSort } = useTree(true); const [showDropdown, setShowDropdown] = useState(false); - const { scenes, activeSceneId, activeScene, collections, activeCollection } = useVuex(() => ({ - scenes: ScenesService.views.scenes.map(scene => ({ - title: , - key: scene.id, - selectable: true, - isLeaf: true, - })), - activeScene: ScenesService.views.activeScene, - activeSceneId: ScenesService.views.activeSceneId, - activeCollection: SceneCollectionsService.activeCollection, - collections: SceneCollectionsService.collections, - })); + const { scenes, activeSceneId, activeScene, collections, activeCollection, isLoggedIn } = useVuex( + () => ({ + scenes: ScenesService.views.scenes.map(scene => ({ + title: , + key: scene.id, + selectable: true, + isLeaf: true, + })), + activeScene: ScenesService.views.activeScene, + activeSceneId: ScenesService.views.activeSceneId, + activeCollection: SceneCollectionsService.activeCollection, + collections: SceneCollectionsService.collections, + isLoggedIn: UserService.views.isLoggedIn, + }), + ); function showContextMenu(info: { event: React.MouseEvent }) { info.event.preventDefault(); @@ -178,12 +182,14 @@ function SceneSelector() { - - AutomationsService.actions.showAutomations()} - /> - + {isLoggedIn && ( + + AutomationsService.actions.showAutomations()} + /> + + )}
a.label.localeCompare(b.label)); export default function EditAutomations() { - const { AutomationsService, AutomationsEngineService, ScenesService, SourcesService } = Services; - const { automations, loading, scenes, sources } = useVuex(() => ({ + const { + AutomationsService, + AutomationsEngineService, + AgentSocketService, + ScenesService, + SourcesService, + } = Services; + const { automations, loaded, error, scenes, sources } = useVuex(() => ({ automations: AutomationsService.state.automations, - loading: AutomationsService.state.loading, + loaded: AutomationsService.state.loaded, + error: AutomationsService.state.error, scenes: ScenesService.views.scenes.map(s => ({ id: s.id, name: s.name })), sources: SourcesService.views.sources.map(s => ({ id: s.sourceId, name: s.name })), })); @@ -60,6 +68,13 @@ export default function EditAutomations() { AutomationsService.actions.fetchAll(); }, []); + function retryNow() { + // fetchAll() alone can't help if the socket itself never connected — force + // a fresh connection attempt too, not just another doomed-to-timeout call. + AgentSocketService.actions.reconnect(); + AutomationsService.actions.fetchAll(); + } + const { WindowsService } = Services; const { editAutomationId, createNew } = useVuex(() => ({ editAutomationId: WindowsService.state.child.queryParams?.editAutomationId as @@ -210,9 +225,18 @@ export default function EditAutomations() { - {loading &&
{$t('Loading...')}
} + {!loaded && !error && } + + {error && ( +
+ {$t('Unable to reach the automations server. Retrying…')} + +
+ )} - {!loading && automations.length === 0 && ( + {loaded && automations.length === 0 && (
)} - {!loading && automations.length > 0 && filtered.length === 0 && ( + {loaded && automations.length > 0 && filtered.length === 0 && (
{$t('No automations match the selected filter.')}
)} - {!loading && filtered.length > 0 && ( + {loaded && filtered.length > 0 && ( diff --git a/app/i18n/en-US/stream-avatar-automations.json b/app/i18n/en-US/stream-avatar-automations.json index c5ead1874b84..c8c5f2d95abb 100644 --- a/app/i18n/en-US/stream-avatar-automations.json +++ b/app/i18n/en-US/stream-avatar-automations.json @@ -35,6 +35,9 @@ "Saving...": "Saving...", "Delete this automation?": "Delete this automation?", "Loading...": "Loading...", + "Unable to reach the automations server. Retrying…": "Unable to reach the automations server. Retrying…", + "Retry Now": "Retry Now", + "Log in to use Automations.": "Log in to use Automations.", "Test automation": "Test automation", "Enabled": "Enabled", "Disabled": "Disabled", diff --git a/app/services/stream-avatar/agent-socket-service.ts b/app/services/stream-avatar/agent-socket-service.ts index bd8dbd9a2616..407b4302365d 100644 --- a/app/services/stream-avatar/agent-socket-service.ts +++ b/app/services/stream-avatar/agent-socket-service.ts @@ -1,4 +1,5 @@ -import { InitAfter, Inject, Service } from 'services/core'; +import { InitAfter, Inject } from 'services/core'; +import { StatefulService, mutation } from 'services/core/stateful-service'; import { StreamAvatarApiService } from './stream-avatar-api-service'; import { HostsService } from 'services/hosts'; import { UserService } from 'services/user'; @@ -11,8 +12,34 @@ interface SocketAck { error?: string; } +export type TAgentSocketStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; + +interface IAgentSocketState { + status: TAgentSocketStatus; +} + +/** How many consecutive connect_error events (without an authenticated in between) + * before we surface a real error state instead of quietly relying on socket.io's + * own indefinite reconnection. */ +const ERROR_AFTER_CONSECUTIVE_FAILURES = 3; + +/** Bounds how long `call()` will wait for the socket to authenticate, so a call + * fails fast (and can be retried/surfaced) instead of hanging forever if the + * server never comes back. */ +const READY_TIMEOUT_MS = 15000; + +/** Backoff for retrying `connect()` itself when it fails before ever creating a + * socket (e.g. the token-mint fetch fails outright) — socket.io's own + * `reconnection` option can't help here since no socket instance exists yet. */ +const CONNECT_RETRY_BASE_DELAY_MS = 2000; +const CONNECT_RETRY_MAX_DELAY_MS = 30000; + @InitAfter('UserService') -export class AgentSocketService extends Service { +export class AgentSocketService extends StatefulService { + static initialState: IAgentSocketState = { + status: 'disconnected', + }; + @Inject() private streamAvatarApiService: StreamAvatarApiService; @Inject() private hostsService: HostsService; @Inject() private userService: UserService; @@ -20,6 +47,9 @@ export class AgentSocketService extends Service { private socket: SocketIOClient.Socket | null = null; private readyPromise: Promise = Promise.resolve(); private readyResolve: (() => void) | null = null; + private consecutiveErrors = 0; + private connectRetryTimer: number | null = null; + private connectRetryDelay = CONNECT_RETRY_BASE_DELAY_MS; init() { console.log('[AgentSocket] init() called. isWorkerWindow:', Utils.isWorkerWindow()); @@ -34,6 +64,45 @@ export class AgentSocketService extends Service { this.resetReady(); this.connect(); }); + this.userService.userLogout.subscribe(() => { + console.log('[AgentSocket] userLogout fired, disconnecting'); + this.clearConnectRetry(); + this.socket?.disconnect(); + this.socket = null; + this.resetReady(); + this.SET_STATUS('disconnected'); + }); + } + + /** Force a fresh connection attempt now, e.g. from a manual "Retry Now" UI action. */ + reconnect() { + this.clearConnectRetry(); + this.consecutiveErrors = 0; + this.resetReady(); + this.connect(); + } + + private clearConnectRetry() { + if (this.connectRetryTimer !== null) { + clearTimeout(this.connectRetryTimer); + this.connectRetryTimer = null; + } + this.connectRetryDelay = CONNECT_RETRY_BASE_DELAY_MS; + } + + private scheduleConnectRetry() { + if (this.connectRetryTimer !== null) return; + console.log(`[AgentSocket] scheduling reconnect attempt in ${this.connectRetryDelay}ms`); + this.connectRetryTimer = window.setTimeout(() => { + this.connectRetryTimer = null; + if (this.userService.isLoggedIn) this.connect(); + }, this.connectRetryDelay); + this.connectRetryDelay = Math.min(this.connectRetryDelay * 2, CONNECT_RETRY_MAX_DELAY_MS); + } + + @mutation() + private SET_STATUS(status: TAgentSocketStatus) { + this.state.status = status; } private resetReady() { @@ -48,6 +117,7 @@ export class AgentSocketService extends Service { } private async connect() { + this.SET_STATUS('connecting'); try { console.log('[AgentSocket] connect() start. URL:', this.socketUrl); const io = (await importSocketIOClient()).default; @@ -78,6 +148,9 @@ export class AgentSocketService extends Service { this.socket.on('authenticated', (payload: any) => { console.log('[AgentSocket] "authenticated" event received', payload); + this.consecutiveErrors = 0; + this.clearConnectRetry(); + this.SET_STATUS('connected'); this.readyResolve?.(); }); @@ -88,10 +161,16 @@ export class AgentSocketService extends Service { this.socket.on('disconnect', (reason: any) => { console.warn('[AgentSocket] "disconnect" event:', reason); this.resetReady(); + if (this.state.status !== 'error') this.SET_STATUS('disconnected'); }); this.socket.on('connect_error', async (err: any) => { console.error('[AgentSocket] "connect_error" event:', err?.message, err); + this.consecutiveErrors += 1; + if (this.consecutiveErrors >= ERROR_AFTER_CONSECUTIVE_FAILURES) { + this.SET_STATUS('error'); + } + if (err?.message?.includes('unauthorized') || err?.message?.includes('auth')) { try { const freshJwt = await this.streamAvatarApiService.getToken(true); @@ -110,6 +189,13 @@ export class AgentSocketService extends Service { this.socket.connect(); } catch (e: unknown) { console.error('[AgentSocket] connect() threw:', e); + this.consecutiveErrors += 1; + if (this.consecutiveErrors >= ERROR_AFTER_CONSECUTIVE_FAILURES) { + this.SET_STATUS('error'); + } + // We never got as far as creating a socket, so socket.io's own + // `reconnection` option has nothing to retry — schedule our own attempt. + this.scheduleConnectRetry(); } } @@ -118,7 +204,7 @@ export class AgentSocketService extends Service { `[AgentSocket] call("${event}") awaiting ready... socket connected:`, this.socket?.connected, ); - await this.readyPromise; + await this.waitForReady(); console.log(`[AgentSocket] call("${event}") ready resolved, emitting`); return new Promise((resolve, reject) => { this.socket!.emit(event, ...args, (res: SocketAck) => { @@ -129,6 +215,18 @@ export class AgentSocketService extends Service { }); } + private waitForReady(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Automations server connection timed out')); + }, READY_TIMEOUT_MS); + this.readyPromise.then(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + getAutomations(): Promise { return this.call('getAutomations'); } diff --git a/app/services/stream-avatar/automations-service.ts b/app/services/stream-avatar/automations-service.ts index 3420db728449..7556c83510a3 100644 --- a/app/services/stream-avatar/automations-service.ts +++ b/app/services/stream-avatar/automations-service.ts @@ -12,20 +12,28 @@ interface IAutomationsState { automations: TAutomationExport[]; loading: boolean; loaded: boolean; + error: boolean; } +const RETRY_BASE_DELAY_MS = 2000; +const RETRY_MAX_DELAY_MS = 30000; + @InitAfter('UserService') export class AutomationsService extends StatefulService { static initialState: IAutomationsState = { automations: [], loading: false, loaded: false, + error: false, }; @Inject() private agentSocketService: AgentSocketService; @Inject() private userService: UserService; @Inject() private windowsService: WindowsService; + private retryDelay = RETRY_BASE_DELAY_MS; + private retryTimer: number | null = null; + init() { console.log('[AutomationsService] init() called. isWorkerWindow:', Utils.isWorkerWindow()); if (!Utils.isWorkerWindow()) return; @@ -37,6 +45,15 @@ export class AutomationsService extends StatefulService { console.log('[AutomationsService] userLogin fired, fetching'); this.fetchAll(); }); + this.userService.userLogout.subscribe(() => { + console.log('[AutomationsService] userLogout fired, resetting'); + if (this.retryTimer !== null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.retryDelay = RETRY_BASE_DELAY_MS; + this.RESET(); + }); } showAutomations() { @@ -79,6 +96,7 @@ export class AutomationsService extends StatefulService { this.state.automations = automations; this.state.loaded = true; this.state.loading = false; + this.state.error = false; } @mutation() @@ -86,6 +104,11 @@ export class AutomationsService extends StatefulService { this.state.loading = loading; } + @mutation() + private SET_ERROR(error: boolean) { + this.state.error = error; + } + @mutation() private ADD_AUTOMATION(automation: TAutomationExport) { this.state.automations = [automation, ...this.state.automations]; @@ -103,19 +126,43 @@ export class AutomationsService extends StatefulService { this.state.automations = this.state.automations.filter(a => a.id !== id); } + @mutation() + private RESET() { + this.state.automations = []; + this.state.loading = false; + this.state.loaded = false; + this.state.error = false; + } + async fetchAll(): Promise { console.log('[AutomationsService] fetchAll() start'); + if (this.retryTimer !== null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } this.SET_LOADING(true); try { const automations = await this.agentSocketService.getAutomations(); console.log('[AutomationsService] fetchAll() got', automations?.length, 'automations'); this.SET_AUTOMATIONS(automations as TAutomationExport[]); + this.retryDelay = RETRY_BASE_DELAY_MS; } catch (e: unknown) { this.SET_LOADING(false); + this.SET_ERROR(true); console.error('[AutomationsService] fetchAll failed', e); + this.scheduleRetry(); } } + private scheduleRetry() { + if (this.retryTimer !== null) return; + this.retryTimer = window.setTimeout(() => { + this.retryTimer = null; + if (this.userService.isLoggedIn) this.fetchAll(); + }, this.retryDelay); + this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY_MS); + } + async create(automation: Omit): Promise { const created = await this.agentSocketService.createAutomation(automation); this.ADD_AUTOMATION(created as TAutomationExport); From 2ee31cd0cc9afa1929947a9414b5a16f60df2630 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Wed, 8 Jul 2026 14:20:59 -0700 Subject: [PATCH 55/79] feat(automations): implement analytics tracking for automation actions and events --- .../AutomationEditor.tsx | 7 +++++++ .../AutomationsAnalytics.ts | 21 +++++++++++++++++++ .../EditAutomations.tsx | 2 ++ .../PreMadeAutomations.tsx | 6 ++++++ .../automations-engine-service.ts | 9 ++++++++ app/services/usage-statistics.ts | 3 ++- 6 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 app/components-react/windows/stream-avatar-automations/AutomationsAnalytics.ts diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index 1759d6662971..649f0a903611 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -16,6 +16,7 @@ import { MAX_INSTRUCTION_LENGTH, } from 'services/stream-avatar/engine/validation'; import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; +import { AutomationsAnalytics } from './AutomationsAnalytics'; import type { ActionType, ExportedAction, @@ -523,10 +524,16 @@ export default function AutomationEditor({ initial, onClose, onViewTemplates }: enabled, }; + const game = payload.conditions[0]?.type.split('.')[0] ?? 'unknown'; + const trigger = payload.conditions[0]?.type ?? 'unknown'; + const actionTypes = payload.actions.map((a: { type: string }) => a.type); + if (initial?.id) { await AutomationsService.actions.update(initial.id, payload); + AutomationsAnalytics.automationUpdated(game, trigger, actionTypes); } else { await AutomationsService.actions.create(payload); + AutomationsAnalytics.automationCreated(game, trigger, actionTypes); } onClose(); } catch (e: unknown) { diff --git a/app/components-react/windows/stream-avatar-automations/AutomationsAnalytics.ts b/app/components-react/windows/stream-avatar-automations/AutomationsAnalytics.ts new file mode 100644 index 000000000000..8a3e664e51d6 --- /dev/null +++ b/app/components-react/windows/stream-avatar-automations/AutomationsAnalytics.ts @@ -0,0 +1,21 @@ +import { Services } from 'components-react/service-provider'; + +type TAutomationsAction = + | 'page_view' + | 'template_added' + | 'automation_created' + | 'automation_updated' + | 'automation_fired'; + +export const AutomationsAnalytics = { + track(action: TAutomationsAction, payload?: Record) { + Services.UsageStatisticsService.recordAnalyticsEvent('Automations', { action, ...payload }); + }, + pageView: () => AutomationsAnalytics.track('page_view'), + templateAdded: (game: string, trigger: string, actions: string[]) => + AutomationsAnalytics.track('template_added', { game, trigger, actions }), + automationCreated: (game: string, trigger: string, actions: string[]) => + AutomationsAnalytics.track('automation_created', { game, trigger, actions }), + automationUpdated: (game: string, trigger: string, actions: string[]) => + AutomationsAnalytics.track('automation_updated', { game, trigger, actions }), +}; diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index e03368bc1370..8302a7797e0c 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -12,6 +12,7 @@ import { validateAutomation } from 'services/stream-avatar/engine/validation'; import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; import AutomationEditor from './AutomationEditor'; import PreMadeAutomations from './PreMadeAutomations'; +import { AutomationsAnalytics } from './AutomationsAnalytics'; import styles from './EditAutomations.m.less'; function conditionLabel(condition: { type: string } | null) { @@ -65,6 +66,7 @@ export default function EditAutomations() { const [simulatingId, setSimulatingId] = useState(null); useEffect(() => { + AutomationsAnalytics.pageView(); AutomationsService.actions.fetchAll(); }, []); diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx index 02ffad857e1c..ceb885f3ed26 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx @@ -5,6 +5,7 @@ import { Services } from 'components-react/service-provider'; import { $t } from 'services/i18n'; import type { ConditionType } from 'services/stream-avatar/engine/conditions'; import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; +import { AutomationsAnalytics } from './AutomationsAnalytics'; import styles from './PreMadeAutomations.m.less'; interface PreMadeItem { @@ -272,6 +273,11 @@ export default function PreMadeAutomations({ onClose }: Props) { } await AutomationsService.actions.create(item.automation); + AutomationsAnalytics.templateAdded( + item.automation.conditions[0]?.type.split('.')[0] ?? 'unknown', + item.automation.conditions[0]?.type ?? 'unknown', + item.automation.actions.map(a => a.type), + ); } } finally { setSaving(false); diff --git a/app/services/stream-avatar/automations-engine-service.ts b/app/services/stream-avatar/automations-engine-service.ts index d38a8a479d54..918cb523bb3e 100644 --- a/app/services/stream-avatar/automations-engine-service.ts +++ b/app/services/stream-avatar/automations-engine-service.ts @@ -6,6 +6,7 @@ import { WebsocketService } from 'services/websocket'; import { VisionService, VisionProcess } from 'services/vision'; import { AutomationsService } from './automations-service'; import { AgentSocketService } from './agent-socket-service'; +import { UsageStatisticsService } from 'services/usage-statistics'; import Utils from 'services/utils'; import { toUpper } from 'lodash'; import { @@ -36,6 +37,7 @@ export class AutomationsEngineService extends Service { @Inject() private visionService: VisionService; @Inject() private automationsService: AutomationsService; @Inject() private agentSocketService: AgentSocketService; + @Inject() private usageStatisticsService: UsageStatisticsService; private gameState: GameState = { ...defaultGameState, pendingEvents: new Set() }; private prevState: GameState = { ...defaultGameState, pendingEvents: new Set() }; @@ -259,6 +261,13 @@ export class AutomationsEngineService extends Service { const conditionsNotMet = conditionResults.some(r => !r.status); + this.usageStatisticsService.recordAnalyticsEvent('Automations', { + action: 'automation_fired', + game: currentGame, + trigger: conditionResults[0]?.condition.type ?? 'unknown', + actions: automation.actions.map(a => a.type), + }); + for (const exportedAction of automation.actions) { try { const [action] = await Actions.fromExported([exportedAction as any], this.actionContext); diff --git a/app/services/usage-statistics.ts b/app/services/usage-statistics.ts index fd038c6138f9..10d7cfd3de65 100644 --- a/app/services/usage-statistics.ts +++ b/app/services/usage-statistics.ts @@ -60,7 +60,8 @@ export type TAnalyticsEvent = | 'Onboarding' | 'WidgetAdded' | 'WidgetRemoved' - | 'GamePulse'; + | 'GamePulse' + | 'Automations'; // Refls are used as uuids for ultra components and should be updated for new ulta components. export type TUltraRefl = From 13435eacc1c8d42bb817fdc86c11b0f43ba9b33c Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Thu, 9 Jul 2026 08:46:10 -0700 Subject: [PATCH 56/79] feat(automations): enhance UI layout and functionality for pre-made automations --- .../PreMadeAutomations.m.less | 298 ++++++++--- .../PreMadeAutomations.tsx | 466 ++++++++---------- .../stream-avatar/agent-socket-service.ts | 19 + .../stream-avatar/automations-service.ts | 6 +- 4 files changed, 475 insertions(+), 314 deletions(-) diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less index 0882117ced63..3609471cdb2c 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less @@ -2,102 +2,228 @@ display: flex; flex-direction: column; align-items: center; - padding: 8px 0 8px; + padding: 16px; overflow: hidden; } -.gameIcon { - width: 56px; - height: 56px; - border-radius: 50%; - background: var(--icon-active); +.mainRow { display: flex; - align-items: center; - justify-content: center; - font-size: 24px; - color: #000; - margin-bottom: 12px; + gap: 16px; + width: 100%; + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + background: #353e47; } -.title { - margin: 0 0 20px; - font-size: 32px; - font-weight: 400; - color: var(--title); - text-align: center; +// Left panel — game cover +.coverPanel { + position: relative; + width: 320px; + height: 260px; + flex-shrink: 0; + border-radius: 6px; + overflow: hidden; + background: var(--section); +} + +.coverVideo { + width: 100%; + height: 100%; + object-fit: cover; + display: block; } -.featured { +.coverTopOverlay { + position: absolute; + top: 10px; + left: 10px; display: flex; align-items: center; - margin-bottom: 16px; + gap: 6px; + background: #09161d8c; + border-radius: 10px; + padding: 6px 10px; } -.card { - border-radius: 8px; - overflow: hidden; - border: 1px solid var(--border); +.coverBottomOverlay { + position: absolute; + bottom: 10px; + left: 10px; + color: #fff; + font-size: 12px; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); } -.preview { - width: 539px; - height: 300px; - overflow: hidden; - background: var(--section); +.badge { + width: 26px; + height: 26px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + color: #fff; } -.previewVideo { - width: 100%; - height: 100%; - object-fit: cover; - display: block; +.coverGameName { + color: #fff; + font-size: 16px; + font-weight: 700; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); +} + +// Right panel — checklist +.checklistPanel { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; } -.infoRow { +.checklistHeader { display: flex; align-items: center; justify-content: space-between; - padding: 12px 16px; - background: var(--section); - gap: 12px; + font-size: 13px; + color: var(--title); + margin-bottom: 8px; + + a { + color: var(--teal); + cursor: pointer; + } +} + +.checklist { + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; +} + +.checklistRow { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + + &:hover { + background: #80f5d21a; + border-color: #80f5d2; + } +} + +.checklistRowActive { + border-color: #80f5d2; + background: #80f5d21a; +} + +.rowThumb { + width: 64px; + height: 40px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; } -.itemTitle { - font-size: 15px; +.rowText { + flex: 1; + min-width: 0; +} + +.rowTitle { + font-size: 13px; font-weight: 700; color: var(--title); - margin-bottom: 2px; } -.itemDesc { - font-size: 12px; +.rowDesc { + font-size: 11px; color: var(--paragraph); } -.thumbnailStrip { +.rowCheck { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid #ffffff80; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: #000; + font-size: 12px; +} + +.rowCheckActive { + border-color: #80f5d2; + background: #80f5d2; +} + +// Bottom — game carousel +.gameCards { display: flex; - gap: 8px; - margin-bottom: 10px; + justify-content: center; + gap: 10px; + width: 100%; overflow-x: auto; - max-width: 100%; + margin-bottom: 8px; } -.thumbnail { - width: 175px; - height: 86px; - border-radius: 4px; - border: 2px solid transparent; - cursor: pointer; +// ponytail: border-width stays 3px in every state (only border-color +// toggles) so the box model never changes size — that's what stops the +// hover jump. background-clip: padding-box keeps `.gameCard`'s own +// background from painting under the border ring, so a transparent border +// is genuinely invisible instead of showing as a dark band. +.gameCard { + width: 230px; + height: 78px; + box-sizing: border-box; flex-shrink: 0; + border-radius: 10px; + border: 3px solid transparent; + background: var(--section); + background-clip: padding-box; overflow: hidden; - position: relative; + cursor: pointer; &:hover { - border-color: var(--icon-active); + border-color: #ffffff; + } +} + +.gameCardActive { + border-color: #ffffff; +} + +.gameCardThumb { + position: relative; + width: 100%; + height: 100%; + + &::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + background: linear-gradient( + 0deg, + rgba(9, 22, 29, 0.92) 22.6%, + rgba(19, 23, 26, 0.6) 55%, + rgba(9, 22, 29, 0) 100% + ); } } -.thumbnailVideo { +.gameCardVideo { width: 100%; height: 100%; object-fit: cover; @@ -105,8 +231,66 @@ pointer-events: none; } -.thumbnailActive { - border-color: var(--icon-active); +.gameCardBadge { + position: absolute; + top: 50%; + left: 8px; + transform: translateY(-50%); + width: 26px; + height: 26px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + color: #fff; + z-index: 2; +} + +.gameCardName { + position: absolute; + top: 50%; + left: 40px; + transform: translateY(-50%); + color: #fff; + font-size: 16px; + font-weight: 700; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); + z-index: 2; +} + +// ponytail: antd's own `.ant-switch { position: relative }` has the same +// specificity as a plain `.gameCardSwitch { position: absolute }` and can win +// depending on stylesheet injection order — which knocks the switch out of +// flow and lets `.gameCard`'s `overflow: hidden` clip it entirely. Pairing our +// class with `:global(.ant-switch)` guarantees higher specificity so our +// positioning always wins; colors are left to the app's default theme. +.gameCardSwitch { + &:global(.ant-switch) { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + background: rgba(9, 22, 29, 0.7); + box-shadow: inset 0 0 0 1px #ffffff80; + } + + :global(.ant-switch-handle)::before { + background: #ffffff; + } +} + +.gameCardSub { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 6px 8px; + font-size: 12px; + color: #e3e8eb; + text-align: center; + z-index: 2; } .dots { diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx index ceb885f3ed26..2e332960f400 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx @@ -1,133 +1,24 @@ -import React, { useState } from 'react'; -import { Button, Switch } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { Button, Spin, Switch } from 'antd'; +import cx from 'classnames'; import { ModalLayout } from 'components-react/shared/ModalLayout'; import { Services } from 'components-react/service-provider'; import { $t } from 'services/i18n'; -import type { ConditionType } from 'services/stream-avatar/engine/conditions'; -import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; +import type { + AutomationTemplateGame, + AutomationTemplateItem, +} from 'services/stream-avatar/agent-socket-service'; import { AutomationsAnalytics } from './AutomationsAnalytics'; import styles from './PreMadeAutomations.m.less'; -interface PreMadeItem { - title: string; - description: string; - gameName: string; - color: string; - /** CDN video URL shown in the carousel preview */ - src: string; - /** When set, a hidden ffmpeg_source is created in the active scene */ - source?: { - name: string; - assetKey: string; - downloadUrl: string; - loop: boolean; - }; - automation: Omit; +// ponytail: badge color is a deterministic hash of the game name, not a server +// field — good enough for a letter badge without inventing a color-config surface. +const BADGE_COLORS = ['#7c5cff', '#f97316', '#22c55e', '#ef4444', '#06b6d4', '#eab308']; +function badgeColor(name: string): string { + const hash = name.split('').reduce((h, c) => h + c.charCodeAt(0), 0); + return BADGE_COLORS[hash % BADGE_COLORS.length]; } -const PRE_MADE: PreMadeItem[] = [ - { - title: 'Victory Royale', - description: 'Co-host comments on each game win.', - gameName: 'Fortnite', - color: '#1a3a5c', - src: 'https://cdn-avatar-builds.streamlabs.com/assets/victory-royale-celebration.webm', - source: { - name: 'victory-royale', - assetKey: 'victory-royale-celebration-animation.webm', - downloadUrl: - 'https://cdn-avatar-builds.streamlabs.com/assets/victory-royale-celebration-animation.webm', - loop: false, - }, - automation: { - description: 'Victory Royale', - enabled: true, - conditions: [{ type: 'fortnite.victory_royale' as ConditionType }], - actions: [ - { type: 'common.show_source', props: { source: { name: 'victory-royale' } } }, - { type: 'co-host.comment' }, - { type: 'common.wait_for_ms', props: { duration: 5000 } }, - { type: 'common.hide_source', props: { source: { name: 'victory-royale' } } }, - ], - }, - }, - { - title: 'Enemy Eliminated', - description: 'Co-host comments on enemy elimination.', - gameName: 'Fortnite', - color: '#2d4a1e', - src: 'https://cdn-avatar-builds.streamlabs.com/assets/player-killed.webm', - source: { - name: 'player-killed', - assetKey: 'player-killed-animation.webm', - downloadUrl: 'https://cdn-avatar-builds.streamlabs.com/assets/player-killed-animation.webm', - loop: false, - }, - automation: { - description: 'Enemy Eliminated', - enabled: true, - conditions: [{ type: 'fortnite.elimination' as ConditionType }], - actions: [ - { type: 'common.show_source', props: { source: { name: 'player-killed' } } }, - { type: 'co-host.comment' }, - { type: 'common.wait_for_ms', props: { duration: 5000 } }, - { type: 'common.hide_source', props: { source: { name: 'player-killed' } } }, - ], - }, - }, - { - title: 'Low Player Health', - description: 'Co-host comments when player health is low.', - gameName: 'Fortnite', - color: '#3a1a1a', - src: 'https://cdn-avatar-builds.streamlabs.com/assets/low-player-health.webm', - source: { - name: 'low-player-health', - assetKey: 'low-player-health-animation', - downloadUrl: - 'https://cdn-avatar-builds.streamlabs.com/assets/low-player-health-animation.webm', - loop: true, - }, - automation: { - description: 'Low Player Health', - enabled: true, - conditions: [{ type: 'fortnite.low_health' as ConditionType }], - actions: [ - { - type: 'common.show_source', - props: { source: { name: 'low-player-health' }, hide_if_condition_false: true }, - }, - { type: 'co-host.comment' }, - ], - }, - }, - { - title: 'Player Eliminated', - description: 'Co-host comments when you are eliminated.', - gameName: 'Fortnite', - color: '#2a1a3a', - src: 'https://cdn-avatar-builds.streamlabs.com/assets/player-eliminated.webm', - source: { - name: 'player-eliminated', - assetKey: 'player-eliminated-animation.webm', - downloadUrl: - 'https://cdn-avatar-builds.streamlabs.com/assets/player-eliminated-animation.webm', - loop: false, - }, - automation: { - description: 'Player Eliminated', - enabled: true, - conditions: [{ type: 'fortnite.player_eliminated' as ConditionType }], - actions: [ - { type: 'common.show_source', props: { source: { name: 'player-eliminated' } } }, - { type: 'co-host.comment' }, - { type: 'common.wait_for_ms', props: { duration: 3000 } }, - { type: 'common.hide_source', props: { source: { name: 'player-eliminated' } } }, - ], - }, - }, -]; - async function downloadAsset(downloadUrl: string, assetKey: string): Promise { try { const os = require('os') as typeof import('os'); @@ -138,13 +29,11 @@ async function downloadAsset(downloadUrl: string, assetKey: string): Promise', savePath); const response = await fetch(downloadUrl); if (!response.ok) throw new Error(`HTTP ${response.status}`); const buffer = await response.arrayBuffer(); - fs.writeFileSync(savePath, Buffer.from(buffer)); - console.log('[downloadAsset] saved', savePath); + fs.writeFileSync(savePath, new Uint8Array(buffer)); return savePath; } catch (e: unknown) { console.error('[downloadAsset] failed:', e); @@ -159,70 +48,35 @@ async function createSourceIfNeeded( loop: boolean, assets: string[], ) { - console.log('[createSourceIfNeeded] start', { - sourceName, - assetKey, - loop, - assetCount: assets.length, - }); - const { ScenesService, SourcesService } = Services; const activeScene = ScenesService.views.activeScene; - if (!activeScene) { - console.warn('[createSourceIfNeeded] no active scene, aborting'); - return; - } - console.log('[createSourceIfNeeded] active scene:', activeScene.id, activeScene.name); + if (!activeScene) return; // Dedup: skip if a source with this name is already in the active scene const existingSource = SourcesService.views.sources.find(s => s.name === sourceName); - console.log( - '[createSourceIfNeeded] existing source:', - existingSource ? existingSource.sourceId : 'none', - ); if (existingSource) { const sceneItems = activeScene.getItems(); const inScene = sceneItems.some((item: any) => item.sourceId === existingSource.sourceId); - console.log( - '[createSourceIfNeeded] already in scene:', - inScene, - '| scene item count:', - sceneItems.length, - ); if (inScene) return; } let assetPath = assets.find(a => a.includes(assetKey)); - console.log( - '[createSourceIfNeeded] asset path:', - assetPath ?? 'NOT FOUND', - '| searched key:', - assetKey, - ); - if (!assetPath) { - console.log('[createSourceIfNeeded] asset not found locally, downloading from:', downloadUrl); assetPath = (await downloadAsset(downloadUrl, assetKey)) ?? undefined; - if (!assetPath) { - console.warn('[createSourceIfNeeded] download failed, aborting'); - return; - } + if (!assetPath) return; } - console.log('[createSourceIfNeeded] creating source:', sourceName, 'at', assetPath); const sceneItemId = await ScenesService.actions.return.createAndAddSource( activeScene.id, sourceName, 'ffmpeg_source', { local_file: assetPath, loop }, ); - console.log('[createSourceIfNeeded] scene item id:', sceneItemId); if (sceneItemId) { const scene = ScenesService.views.getScene(activeScene.id); const sceneItem = scene?.getItem(sceneItemId); sceneItem?.setVisibility(false); - console.log('[createSourceIfNeeded] visibility set to hidden'); } } @@ -231,53 +85,84 @@ interface Props { } export default function PreMadeAutomations({ onClose }: Props) { - const { AutomationsService } = Services; - const [currentIndex, setCurrentIndex] = useState(0); - const [enabledSet, setEnabledSet] = useState>(new Set()); + const { AutomationsService, AgentSocketService } = Services; + const [games, setGames] = useState([]); + const [loading, setLoading] = useState(true); + const [activeGameIndex, setActiveGameIndex] = useState(0); + const [selections, setSelections] = useState>>({}); const [saving, setSaving] = useState(false); - function toggleItem(index: number) { - setEnabledSet(prev => { - const next = new Set(prev); - if (next.has(index)) next.delete(index); - else next.add(index); - return next; - }); + useEffect(() => { + AgentSocketService.getAutomationTemplates() + .then(setGames) + .finally(() => setLoading(false)); + }, []); + + const activeGame: AutomationTemplateGame | undefined = games[activeGameIndex]; + const activeSelection = (activeGame && selections[activeGame.game]) ?? new Set(); + + function setGameSelection(gameKey: string, next: Set) { + setSelections(prev => ({ ...prev, [gameKey]: next })); + } + + function toggleTemplate(index: number) { + if (!activeGame) return; + const next = new Set(activeSelection); + if (next.has(index)) next.delete(index); + else next.add(index); + setGameSelection(activeGame.game, next); } - function goTo(index: number) { - setCurrentIndex(index); + function toggleSelectAll() { + if (!activeGame) return; + const allSelected = activeSelection.size === activeGame.templates.length; + setGameSelection( + activeGame.game, + allSelected ? new Set() : new Set(activeGame.templates.map((_, i) => i)), + ); + } + + function toggleGameSwitch(game: AutomationTemplateGame) { + const current = selections[game.game]?.size ?? 0; + setGameSelection(game.game, current > 0 ? new Set() : new Set(game.templates.map((_, i) => i))); } + const totalSelected = Object.values(selections).reduce((sum, set) => sum + set.size, 0); + async function handleComplete() { setSaving(true); try { const assets: string[] = (await (window as any)?.streamlabsOBS?.v1?.NativeComponents?.getAssets?.()) ?? []; - for (const index of enabledSet) { - const item = PRE_MADE[index]; + for (const game of games) { + const indices = selections[game.game]; + if (!indices || indices.size === 0) continue; - if (item.source) { - try { - await createSourceIfNeeded( - item.source.name, - item.source.assetKey, - item.source.downloadUrl, - item.source.loop, - assets, - ); - } catch { - // non-fatal — continue creating the automation + for (const index of indices) { + const item: AutomationTemplateItem = game.templates[index]; + + if (item.source) { + try { + await createSourceIfNeeded( + item.source.name, + item.source.assetKey, + item.source.downloadUrl, + item.source.loop, + assets, + ); + } catch { + // non-fatal — continue creating the automation + } } - } - await AutomationsService.actions.create(item.automation); - AutomationsAnalytics.templateAdded( - item.automation.conditions[0]?.type.split('.')[0] ?? 'unknown', - item.automation.conditions[0]?.type ?? 'unknown', - item.automation.actions.map(a => a.type), - ); + await AutomationsService.actions.create(item.automation); + AutomationsAnalytics.templateAdded( + game.game, + item.automation.conditions[0]?.type ?? 'unknown', + item.automation.actions.map(a => a.type), + ); + } } } finally { setSaving(false); @@ -285,8 +170,6 @@ export default function PreMadeAutomations({ onClose }: Props) { onClose(); } - const current = PRE_MADE[currentIndex]; - const footer = ( <> ); @@ -306,66 +191,139 @@ export default function PreMadeAutomations({ onClose }: Props) { return (
-
- -
- -

{$t('Select Pre-made Automation')}

- -
-
-
-
-
-
-
{current.title}
-
{current.description}
+ {loading || !activeGame ? ( + + ) : ( + <> +
+
+
- toggleItem(currentIndex)} - /> -
-
-
-
- {PRE_MADE.map((item, i) => ( -
goTo(i)} - > -
- ))} -
-
- {PRE_MADE.map((_, i) => ( - goTo(i)} - /> - ))} -
+ {games.length > 1 && ( + <> +
+ {games.map((game, i) => { + const selectedCount = selections[game.game]?.size ?? 0; + return ( +
setActiveGameIndex(i)} + > +
+
+
+ ); + })} +
+
+ {games.map((_, i) => ( + setActiveGameIndex(i)} + /> + ))} +
+ + )} + + )}
); diff --git a/app/services/stream-avatar/agent-socket-service.ts b/app/services/stream-avatar/agent-socket-service.ts index 407b4302365d..d03dc892c2b0 100644 --- a/app/services/stream-avatar/agent-socket-service.ts +++ b/app/services/stream-avatar/agent-socket-service.ts @@ -5,6 +5,7 @@ import { HostsService } from 'services/hosts'; import { UserService } from 'services/user'; import { importSocketIOClient } from 'util/slow-imports'; import Utils from 'services/utils'; +import type { TAutomationExport } from './engine/automations'; interface SocketAck { ok: boolean; @@ -12,6 +13,20 @@ interface SocketAck { error?: string; } +export interface AutomationTemplateItem { + title: string; + description: string; + videoUrl: string; + source?: { name: string; assetKey: string; downloadUrl: string; loop: boolean }; + automation: Omit; +} + +export interface AutomationTemplateGame { + game: string; + gameName: string; + templates: AutomationTemplateItem[]; +} + export type TAgentSocketStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; interface IAgentSocketState { @@ -231,6 +246,10 @@ export class AgentSocketService extends StatefulService { return this.call('getAutomations'); } + getAutomationTemplates(): Promise { + return this.call('getAutomationTemplates'); + } + createAutomation(automation: any): Promise { return this.call('createAutomation', automation); } diff --git a/app/services/stream-avatar/automations-service.ts b/app/services/stream-avatar/automations-service.ts index 7556c83510a3..62f1c54c79de 100644 --- a/app/services/stream-avatar/automations-service.ts +++ b/app/services/stream-avatar/automations-service.ts @@ -62,7 +62,7 @@ export class AutomationsService extends StatefulService { title: $t('Automations'), size: { width: 900, - height: 750, + height: 625, }, }); } @@ -73,7 +73,7 @@ export class AutomationsService extends StatefulService { title: $t('Automations'), size: { width: 900, - height: 750, + height: 625, }, queryParams: { editAutomationId: id }, }); @@ -85,7 +85,7 @@ export class AutomationsService extends StatefulService { title: $t('Automations'), size: { width: 900, - height: 750, + height: 625, }, queryParams: { createNew: true }, }); From c2d2544a8a531d68f2b74dfd2f5f554cc7e96cc7 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Thu, 9 Jul 2026 09:30:56 -0700 Subject: [PATCH 57/79] feat(automations): improve localization and dynamic labels in automation components --- .../EditAutomations.tsx | 4 +--- .../PreMadeAutomations.tsx | 18 ++++++++++++------ app/i18n/en-US/stream-avatar-automations.json | 9 ++++++++- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index 8302a7797e0c..c2cb9221eb5b 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -349,9 +349,7 @@ export default function EditAutomations() { okText={$t('Delete')} cancelText={$t('Cancel')} > - - - +
diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx index 2e332960f400..bb90f595798e 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx @@ -170,6 +170,11 @@ export default function PreMadeAutomations({ onClose }: Props) { onClose(); } + const addLabel = + totalSelected === 1 + ? $t('Add %{count} Automation', { count: totalSelected }) + : $t('Add %{count} Automations', { count: totalSelected }); + const footer = ( <> ); @@ -216,7 +219,7 @@ export default function PreMadeAutomations({ onClose }: Props) { {activeGame.gameName}
- {activeGame.templates.length} automations + {$t('%{count} automations', { count: activeGame.templates.length })}
@@ -303,8 +306,11 @@ export default function PreMadeAutomations({ onClose }: Props) { />
{selectedCount > 0 - ? `${selectedCount} of ${game.templates.length} added` - : `${game.templates.length} automations`} + ? $t('%{count} of %{total} added', { + count: selectedCount, + total: game.templates.length, + }) + : $t('%{count} automations', { count: game.templates.length })}
diff --git a/app/i18n/en-US/stream-avatar-automations.json b/app/i18n/en-US/stream-avatar-automations.json index c8c5f2d95abb..94b6180752c2 100644 --- a/app/i18n/en-US/stream-avatar-automations.json +++ b/app/i18n/en-US/stream-avatar-automations.json @@ -80,5 +80,12 @@ "Source \"%{name}\" is unavailable.": "Source \"%{name}\" is unavailable.", "Enter an instruction for the co-host.": "Enter an instruction for the co-host.", "Instruction must be %{max} characters or fewer.": "Instruction must be %{max} characters or fewer.", - "Requires the Intelligent Streaming Agent app to be installed and enabled.": "Requires the Intelligent Streaming Agent app to be installed and enabled." + "Requires the Intelligent Streaming Agent app to be installed and enabled.": "Requires the Intelligent Streaming Agent app to be installed and enabled.", + "What your co-host will react to": "What your co-host will react to", + "Select all": "Select all", + "Unselect all": "Unselect all", + "%{count} automations": "%{count} automations", + "%{count} of %{total} added": "%{count} of %{total} added", + "Add %{count} Automation": "Add %{count} Automation", + "Add %{count} Automations": "Add %{count} Automations" } From ca9c957b6f7282a8afa3cf54de209181c93e1c56 Mon Sep 17 00:00:00 2001 From: AnkhHeart Date: Fri, 10 Jul 2026 07:21:39 -0700 Subject: [PATCH 58/79] feat(automations): refactor AutomationEditor and EditAutomations components, add PreMadeAutomationsFooter for improved UI and functionality --- .../AutomationEditor.tsx | 18 +- .../EditAutomations.m.less | 38 --- .../EditAutomations.tsx | 75 ++--- .../PreMadeAutomations.m.less | 45 ++- .../PreMadeAutomations.tsx | 309 +++++++++--------- .../PreMadeAutomationsFooter.tsx | 38 +++ app/i18n/en-US/stream-avatar-automations.json | 11 +- .../stream-avatar/automations-service.ts | 6 +- 8 files changed, 257 insertions(+), 283 deletions(-) create mode 100644 app/components-react/windows/stream-avatar-automations/PreMadeAutomationsFooter.tsx diff --git a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx index 649f0a903611..570f92ede23e 100644 --- a/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx +++ b/app/components-react/windows/stream-avatar-automations/AutomationEditor.tsx @@ -398,10 +398,9 @@ function ActionEditor({ interface Props { initial?: TAutomationExport; onClose: () => void; - onViewTemplates?: () => void; } -export default function AutomationEditor({ initial, onClose, onViewTemplates }: Props) { +export default function AutomationEditor({ initial, onClose }: Props) { const { AutomationsService, ScenesService, SourcesService } = Services; const { isInstalled: isAgentInstalled, @@ -591,21 +590,6 @@ export default function AutomationEditor({ initial, onClose, onViewTemplates }:

{initial ? $t('Edit Automation') : $t('Add New Automation')}

- {onViewTemplates && ( - - )}
diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less b/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less index 9f51773125a2..739425a16a9c 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.m.less @@ -155,41 +155,3 @@ white-space: nowrap; } -// ── Empty state ─────────────────────────────────────── - -.emptyCard { - border: 1px dashed var(--border); - border-radius: 8px; - padding: 40px 24px; - text-align: center; - margin-top: 8px; -} - -.emptyImage { - width: 260px; - height: 160px; - background: var(--section); - border-radius: 8px; - margin: 0 auto 24px; -} - -.emptyTitle { - margin: 0 0 10px; - font-size: 16px; - font-weight: 700; - color: var(--title); -} - -.emptyDesc { - margin: 0 auto 24px; - max-width: 480px; - font-size: 13px; - color: var(--paragraph); - line-height: 1.5; -} - -.emptyActions { - display: flex; - justify-content: center; - gap: 12px; -} diff --git a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx index c2cb9221eb5b..2a16d8d853c8 100644 --- a/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/EditAutomations.tsx @@ -12,6 +12,7 @@ import { validateAutomation } from 'services/stream-avatar/engine/validation'; import type { TAutomationExport } from 'services/stream-avatar/engine/automations'; import AutomationEditor from './AutomationEditor'; import PreMadeAutomations from './PreMadeAutomations'; +import PreMadeAutomationsFooter from './PreMadeAutomationsFooter'; import { AutomationsAnalytics } from './AutomationsAnalytics'; import styles from './EditAutomations.m.less'; @@ -42,6 +43,8 @@ const GAME_FILTER_OPTIONS = Object.entries(GAME_NAMES) .map(([id, name]) => ({ label: name, value: id })) .sort((a, b) => a.label.localeCompare(b.label)); +type TPreMadeFooterState = { totalSelected: number; saving: boolean; onComplete: () => void }; + export default function EditAutomations() { const { AutomationsService, @@ -64,6 +67,7 @@ export default function EditAutomations() { const [showPreMade, setShowPreMade] = useState(false); const [filterGame, setFilterGame] = useState(''); const [simulatingId, setSimulatingId] = useState(null); + const [preMadeFooter, setPreMadeFooter] = useState(null); useEffect(() => { AutomationsAnalytics.pageView(); @@ -149,20 +153,16 @@ export default function EditAutomations() { } if (creating || editingAutomation) { - return ( - { - closeEditor(); - setShowPreMade(true); - }} - /> - ); + return ; } if (showPreMade) { - return setShowPreMade(false)} />; + return ( + setShowPreMade(false)} + onSaved={() => setShowPreMade(false)} + /> + ); } const filtered = filterGame @@ -179,19 +179,27 @@ export default function EditAutomations() { {$t('Add New Automation')} setShowPreMade(true)}> - {$t('Select from Pre-made')} + {$t('Use Template')} ); - const footer = ( - <> - - - - ); + const footer = + automations.length === 0 && preMadeFooter ? ( + + ) : ( + <> + + + + ); return ( @@ -239,32 +247,7 @@ export default function EditAutomations() { )} {loaded && automations.length === 0 && ( -
-
-

{$t("You don't have Automations set up yet.")}

-

- {$t( - 'Automatically trigger on stream effects including visual effects, transitions, sounds, agent commentary (and more) in response to gameplay events such as kills, wins, knocks, deaths, and much more. See supported games.', - )} -

-
- - -
-
+ )} {loaded && automations.length > 0 && filtered.length === 0 && ( diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less index 3609471cdb2c..3c8a7f441388 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.m.less @@ -6,6 +6,13 @@ overflow: hidden; } +// Embedded in EditAutomations, which already applies its own body/header +// spacing — the standalone padding above would just stack on top of that +// and push the modal past its fixed scrollable height. +.containerEmbedded { + padding: 0; +} + .mainRow { display: flex; gap: 16px; @@ -260,25 +267,27 @@ z-index: 2; } -// ponytail: antd's own `.ant-switch { position: relative }` has the same -// specificity as a plain `.gameCardSwitch { position: absolute }` and can win -// depending on stylesheet injection order — which knocks the switch out of -// flow and lets `.gameCard`'s `overflow: hidden` clip it entirely. Pairing our -// class with `:global(.ant-switch)` guarantees higher specificity so our -// positioning always wins; colors are left to the app's default theme. -.gameCardSwitch { - &:global(.ant-switch) { - position: absolute; - top: 8px; - right: 8px; - z-index: 2; - background: rgba(9, 22, 29, 0.7); - box-shadow: inset 0 0 0 1px #ffffff80; - } +.gameCardCheck { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid #ffffff80; + background: rgba(9, 22, 29, 0.7); + display: flex; + align-items: center; + justify-content: center; + color: #000; + font-size: 12px; + cursor: pointer; +} - :global(.ant-switch-handle)::before { - background: #ffffff; - } +.gameCardCheckActive { + border-color: #ffffff; + background: #ffffff; } .gameCardSub { diff --git a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx index bb90f595798e..04e33b3a1f99 100644 --- a/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx +++ b/app/components-react/windows/stream-avatar-automations/PreMadeAutomations.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Button, Spin, Switch } from 'antd'; +import { Spin } from 'antd'; import cx from 'classnames'; import { ModalLayout } from 'components-react/shared/ModalLayout'; import { Services } from 'components-react/service-provider'; @@ -9,6 +9,7 @@ import type { AutomationTemplateItem, } from 'services/stream-avatar/agent-socket-service'; import { AutomationsAnalytics } from './AutomationsAnalytics'; +import PreMadeAutomationsFooter from './PreMadeAutomationsFooter'; import styles from './PreMadeAutomations.m.less'; // ponytail: badge color is a deterministic hash of the game name, not a server @@ -81,10 +82,17 @@ async function createSourceIfNeeded( } interface Props { - onClose: () => void; + onCancel: () => void; + onSaved?: () => void; + embedded?: boolean; + onFooterChange?: (footer: { + totalSelected: number; + saving: boolean; + onComplete: () => void; + }) => void; } -export default function PreMadeAutomations({ onClose }: Props) { +export default function PreMadeAutomations({ onCancel, onSaved, embedded, onFooterChange }: Props) { const { AutomationsService, AgentSocketService } = Services; const [games, setGames] = useState([]); const [loading, setLoading] = useState(true); @@ -122,13 +130,18 @@ export default function PreMadeAutomations({ onClose }: Props) { ); } - function toggleGameSwitch(game: AutomationTemplateGame) { + function toggleGameSelection(game: AutomationTemplateGame) { const current = selections[game.game]?.size ?? 0; setGameSelection(game.game, current > 0 ? new Set() : new Set(game.templates.map((_, i) => i))); } const totalSelected = Object.values(selections).reduce((sum, set) => sum + set.size, 0); + useEffect(() => { + onFooterChange?.({ totalSelected, saving, onComplete: handleComplete }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [totalSelected, saving]); + async function handleComplete() { setSaving(true); try { @@ -167,170 +180,160 @@ export default function PreMadeAutomations({ onClose }: Props) { } finally { setSaving(false); } - onClose(); + onSaved?.(); } - const addLabel = - totalSelected === 1 - ? $t('Add %{count} Automation', { count: totalSelected }) - : $t('Add %{count} Automations', { count: totalSelected }); - const footer = ( - <> - - - + ); - return ( - -
- {loading || !activeGame ? ( - - ) : ( - <> -
-
-