This commit is contained in:
2026-05-02 05:35:16 +09:00
parent 57aeba4bb8
commit e40a2a568e
36 changed files with 3198 additions and 411 deletions

View File

@@ -0,0 +1,248 @@
# RGB 색상 지침 전수조사
- 작성일: 2026-04-29
- 기준 폴더: `E:\김의연\지역민방\T3_Cut`
- 기준 문서/코드: `RGB_SPEC_CUT_MAPPING.md`, `TSCN_VARIABLE_DISCOVERY_E_DRIVE.md`, `PartyColorCatalog`
- 판정 기준:
- 장면 변수에는 `정당명`, `정당바`, `정당판`, `정당원`, `정당색`, `그룹`, `득표율` 등이 있으나 RGB txt에 같은 항목의 색상 지침이 없으면 “색상 지침 없음”으로 정리했다.
- RGB txt가 컷명과 1:1로 대응하지 않고 shared/family/inferred/naming bridge/historical로 연결되면 “기준 파일 안내 필요”로 정리했다.
- 아래 내용은 RGB txt와 장면 변수 기준 1차 전수조사다. 샘플 이미지와 실제 송출 화면의 육안 색상 차이는 별도 캡처 대조가 필요하다.
## 외부 공유용 핵심 이슈
[Normal] 1-2위_ani_광역단체장
- 정당명 오브젝트가 있으나 RGB txt에는 정당명 색상 지침이 없음
- RGB txt는 `정당판/득표율`, `정당바` 기준만 제공하므로, RGB txt대로 정당명까지 바꾸면 샘플 색상과 다르게 보일 수 있음
- 현재 live 검증은 `정당판01`, `정당바01`, `득표율01``SetStyleColor`만 확인되어 있음
## 색상 지침 누락 의심 컷
[Normal] 1-2위_광역단체장_시도별영상
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `1-2위_광역단체장,기초단체장_시도별영상.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 1-2위_교육감
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
[Normal] 1-2위_기초단체장
- 정당바 오브젝트가 있으나 RGB txt에는 정당바 색상 지침이 없음
- 현재 RGB txt는 득표율 기준만 있음
[Normal] 1-2위_기초단체장_시도별영상
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `1-2위_광역단체장,기초단체장_시도별영상.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 1-3위_ani_광역단체장
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `1-3위_ani_광역단체장,보궐.txt`를 family 기준으로 사용 중이라 기준 파일 안내가 필요함
[Normal] 1-3위_ani_기초단체장
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `1-3위_ani_기초단체장(5760동일).txt`를 family 기준으로 사용 중이라 기준 파일 안내가 필요함
[Normal] 1-3위_기초단체장_5760
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `1-3위_ani_기초단체장(5760동일).txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 1-3위_보궐선거
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `1-3위_ani_광역단체장,보궐.txt`를 추정 연결 중이라 기준 파일 안내가 필요함
[Normal] 모든후보_광역단체장
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `모든후보.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 모든후보_광역단체장_5760
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `모든후보.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 모든후보_교육감
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
[Normal] 모든후보_교육감_5760
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `모든후보_교육감.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 모든후보_기초단체장
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `모든후보.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 모든후보_기초단체장_5760
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `모든후보.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 접전_광역단체장
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `접전,초접전.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 접전_기초단체장
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `접전,초접전.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 초접전_광역단체장
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `접전,초접전.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 초접전_기초단체장
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `접전,초접전.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 이시각1위_광역단체장
- 정당바, 정당색, 그룹 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- 현재 RGB txt는 정당명/득표율 기준만 있음
[Normal] 이시각1위_기초단체장
- 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `이시각1위_기초단체장(5760동일).txt`를 family 기준으로 사용 중이라 기준 파일 안내가 필요함
[Normal] 이시각1위_기초단체장_HD
- 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `이시각1위_기초단체장(5760동일).txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 판세_광역단체장
- 정당명, 정당바 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- 현재 RGB txt는 지역명 기준만 있음
[Normal] 판세_기초단체장
- 득표율 오브젝트가 있으나 RGB txt에는 득표율 색상 지침이 없음
- `판세_광역단체장.txt`를 추정 연결 중이라 기준 파일 안내가 필요함
[Normal] 판세_기초단체장_5760
- 득표율 오브젝트가 있으나 RGB txt에는 득표율 색상 지침이 없음
- `판세_광역단체장.txt`를 추정 연결 중이라 기준 파일 안내가 필요함
[Bottom] 1-3위_광역단체장
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
- `1-2위, 1-3위, 이시각1위.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Bottom] 1-3위_기초단체장
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
- `1-2위, 1-3위, 이시각1위.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Bottom] 1위_광역단체장
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
- `1-2위, 1-3위, 이시각1위.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Bottom] 1위_기초단체장
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
- `1-2위, 1-3위, 이시각1위.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Bottom] 당선_광역단체장
- 정당명, 그룹 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `당선.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Bottom] 당선_광역의원
- 정당명, 그룹 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `당선.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Bottom] 당선_기초단체장
- 정당명, 그룹 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `당선.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Bottom] 당선_기초의원
- 정당명, 그룹 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
- `당선.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Bottom] 전후보_광역단체장
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
- `모든후보.txt`로 naming bridge 연결 중이라 기준 파일 안내가 필요함
[Bottom] 전후보_교육감
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
- `모든후보_교육감.txt`로 naming bridge 연결 중이라 기준 파일 안내가 필요함
[Bottom] 전후보_기초단체장
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
- `모든후보.txt`로 naming bridge 연결 중이라 기준 파일 안내가 필요함
[Top] 광역단체장_2인_텍스트
- 득표율 오브젝트가 있으나 RGB txt에는 득표율 색상 지침이 없음
- `1-2위_텍스트.txt`를 text layout 기준으로 사용 중이라 기준 파일 안내가 필요함
[Top] 기초단체장_2인_텍스트
- 득표율 오브젝트가 있으나 RGB txt에는 득표율 색상 지침이 없음
- `1-2위_텍스트.txt`를 text layout 기준으로 사용 중이라 기준 파일 안내가 필요함
## 기준 파일 안내가 필요한 컷
[Normal] 1-2위_ani_기초단체장_5760
- `1-2위_ani_기초단체장.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 1-2위_광역단체장
- `1-2위_광역단체장, 보궐.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 1-2위_광역단체장_5760
- `1-2위_광역단체장, 보궐.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 1-2위_보궐선거
- `1-2위_광역단체장, 보궐.txt`를 추정 연결 중이라 기준 파일 안내가 필요함
[Normal] 경력_광역단체장_in
- `경력.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 경력_기초단체장_in
- `경력.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 당선_광역단체장/HD, 당선_광역의원/HD, 당선_기초단체장/HD, 당선_기초의원/HD
- `당선.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 당선_교육감_HD
- `당선_교육감.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Normal] 사전_역대당선자, 사전_역대당선자_기초단체장
- `사전_역대당선.txt`를 historical 기준으로 사용 중이라 기준 파일 안내가 필요함
[Normal] 사전_역대당선자_교육감
- `사전_역대당선_교육감.txt`를 historical 기준으로 사용 중이라 기준 파일 안내가 필요함
[Normal] 이시각1위_광역단체장_HD
- `이시각1위_광역단체장.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Bottom] 1-2위_광역단체장, 1-2위_기초단체장
- `1-2위, 1-3위, 이시각1위.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
[Top] 광역단체장_2인, 기초단체장_2인
- `1-2위_사진.txt`를 photo layout 기준으로 사용 중이라 기준 파일 안내가 필요함
## RGB 명시 매핑이 아직 없는 컷
[Normal] 광역의원표
- RGB txt 명시 매핑이 아직 없음
[Normal] 광역의원표_HD
- RGB txt 명시 매핑이 아직 없음
[Normal] 기초의원표
- RGB txt 명시 매핑이 아직 없음
[Normal] 기초의원표_HD
- RGB txt 명시 매핑이 아직 없음
[Normal] 역대시도판세_광역단체장
- RGB txt 명시 매핑이 아직 없음
[Normal] 역대시도판세_기초단체장
- RGB txt 명시 매핑이 아직 없음
[Top] 판세_광역단체장
- RGB txt 명시 매핑이 아직 없음
[Top] 판세_광역의원
- RGB txt 명시 매핑이 아직 없음
[Top] 판세_교육감
- RGB txt 명시 매핑이 아직 없음
[Top] 판세_기초단체장
- RGB txt 명시 매핑이 아직 없음
[Top] 판세_기초의원
- RGB txt 명시 매핑이 아직 없음
## 색상 작업 대상에서 제외해도 되는 컷
[공통] 민방_타이틀*, 사전_역대투표율*, 사전투표율, 투표율*
- 정당색/후보색 중심 컷이 아니라 현재 RGB 정당 색상 이슈 대상에서는 제외 가능함
[공통] 투표율_사진, 투표율_선거구별, 투표율_선거구별 사전, 투표율_시도별, 투표율_영상
- 정당색/후보색 중심 컷이 아니라 현재 RGB 정당 색상 이슈 대상에서는 제외 가능함

View File

@@ -196,12 +196,10 @@
- `TORNADO_KARISMA_BIND_BOTTOM`
- `TORNADO_KARISMA_BIND_VIDEOWALL`
### 9.4 T3_Cut 탐색
### 9.4 T3_Cut 경로
- `TORNADO_T3CUT_PATH`
- `문서\\Tornado3 Data\\T3_Cut\\T3_Cut`
- `문서\\Tornado3 Data\\T3_Cut`
- `다운로드\\T3_Cut`
- 앱, 송출 어댑터, 썸네일 생성기, Karisma 디버깅 도구는 `D:\\Elect2026\\T3_Cut`를 고정 기준 경로로 사용한다.
- 사용자 설정값, 저장된 상태값, `TORNADO_T3CUT_PATH`, 디버깅 도구의 `--image-root`/`--root` 입력은 T3_Cut 기준 경로를 바꾸지 않는다.
### 9.5 폴백

View File

@@ -8,6 +8,8 @@ public enum AppPage
Bottom,
VideoWall,
PreElectionData,
TurnoutData,
CountingData,
Data,
CutList,
Settings,

View File

@@ -7,4 +7,6 @@ public sealed class FormatCutDefinition
public required double DurationSeconds { get; set; }
public int CandidateStartIndex { get; init; }
public bool UseEndScene { get; init; }
}

View File

@@ -49,7 +49,8 @@
<NavigationViewItem Content="하단" Tag="bottom" Visibility="{x:Bind ViewModel.BottomMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Download" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="비디오월" Tag="videowall" Visibility="{x:Bind ViewModel.VideoWallMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Video" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="사전데이터" Tag="pre-election-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Library" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="데이터" Tag="data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="투표데이터" Tag="turnout-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="개표데이터" Tag="counting-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="컷리스트" Tag="cut-list"><NavigationViewItem.Icon><SymbolIcon Symbol="Bullets" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="설정" Tag="settings"><NavigationViewItem.Icon><SymbolIcon Symbol="Setting" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="로그" Tag="log"><NavigationViewItem.Icon><SymbolIcon Symbol="Document" /></NavigationViewItem.Icon></NavigationViewItem>
@@ -101,7 +102,7 @@
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="단계" HorizontalAlignment="Center" />
<ToggleSwitch x:Name="BroadcastPhaseToggleSwitch"
OffContent="사전"
OffContent="투표"
OnContent="개표"
IsOn="{x:Bind ViewModel.Data.IsCountingPhase, Mode=OneWay}"
Toggled="BroadcastPhaseToggleSwitch_Toggled" />
@@ -893,7 +894,7 @@
</StackPanel>
<NumberBox Grid.Column="3"
Minimum="1"
Minimum="{x:Bind MinimumDurationSeconds, Mode=OneWay}"
SmallChange="1"
SpinButtonPlacementMode="Compact"
Value="{x:Bind DurationSeconds, Mode=TwoWay}" />
@@ -917,13 +918,13 @@
<Grid ColumnSpacing="12"><Grid.ColumnDefinitions><ColumnDefinition Width="280" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
<ComboBox DisplayMemberPath="Name" Header="방송사" ItemsSource="{x:Bind ViewModel.Settings.Stations, Mode=OneWay}" SelectedValue="{x:Bind ViewModel.Settings.SelectedStationId, Mode=TwoWay}" SelectedValuePath="Id" />
<Grid Grid.Column="1" ColumnSpacing="10">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
<TextBox IsReadOnly="True"
IsSpellCheckEnabled="False"
Text="{x:Bind ViewModel.Settings.ImageRootPath, Mode=OneWay}">
<TextBox.Header>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="T3_Cut 경로" />
<TextBlock Text="T3_Cut 고정 경로" />
<Button Width="22"
Height="22"
MinWidth="22"
@@ -936,7 +937,7 @@
<Button.Flyout>
<Flyout>
<TextBlock MaxWidth="260"
Text="송출에 사용할 .tscn 컷 폴더입니다. 폴더를 바꾸면 컷 검색 기준도 함께 바뀝니다."
Text="송출과 디버깅에 사용할 .tscn 컷 폴더입니다. 기준 경로는 D:\Elect2026\T3_Cut로 고정됩니다."
TextWrapping="WrapWholeWords" />
</Flyout>
</Button.Flyout>
@@ -944,11 +945,6 @@
</StackPanel>
</TextBox.Header>
</TextBox>
<Button Grid.Column="1"
Click="PickImageRootFolderButton_Click"
Content="폴더 선택"
VerticalAlignment="Bottom"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</Grid>
</Grid>
<Grid ColumnSpacing="12">
@@ -960,7 +956,7 @@
<ComboBox DisplayMemberPath="Label"
Header="비디오월 화면"
ItemsSource="{x:Bind ViewModel.Settings.VideoWallLayoutOptions, Mode=OneWay}"
SelectedValue="{x:Bind ViewModel.Settings.SelectedStationVideoWallLayoutPreset, Mode=TwoWay}"
SelectedValue="{x:Bind ViewModel.Settings.SelectedStationVideoWallLayoutPresetSelection, Mode=TwoWay}"
SelectedValuePath="Value" />
<Border Grid.Column="1"

View File

@@ -176,7 +176,7 @@ public sealed partial class MainWindow : Window
var folder = await picker.PickSingleFolderAsync();
if (folder is not null)
{
ViewModel.Settings.ImageRootPath = folder.Path;
ViewModel.Settings.ImageRootPath = Services.TornadoPathResolver.GetDefaultT3CutPath();
}
}
catch
@@ -202,6 +202,7 @@ public sealed partial class MainWindow : Window
if (confirmed)
{
ViewModel.ApplyBroadcastPhase(targetPhase);
EnsureNavigationSelection();
return;
}
@@ -351,9 +352,9 @@ public sealed partial class MainWindow : Window
return false;
}
var targetLabel = targetPhase == BroadcastPhase.PreElection ? "사전" : "개표";
var targetLabel = targetPhase == BroadcastPhase.PreElection ? "투표" : "개표";
var description = targetPhase == BroadcastPhase.PreElection
? "사전 단계에서는 투표율과 투표자 수 중심으로 수신합니다."
? "투표 단계에서는 투표율과 투표자 수 중심으로 수신합니다."
: "개표 단계에서는 후보 득표수와 당선 판정 중심으로 수신합니다.";
var dialog = new ContentDialog
@@ -458,7 +459,9 @@ public sealed partial class MainWindow : Window
AppPage.Bottom => "bottom",
AppPage.VideoWall => "videowall",
AppPage.PreElectionData => "pre-election-data",
AppPage.Data => "data",
AppPage.TurnoutData => "turnout-data",
AppPage.CountingData => "counting-data",
AppPage.Data => ViewModel.Data.IsPreElectionPhase ? "turnout-data" : "counting-data",
AppPage.CutList => "cut-list",
AppPage.Settings => "settings",
AppPage.Log => "log",

View File

@@ -268,6 +268,7 @@ public sealed class ChannelScheduleEngine
var station = _stationProvider();
var imageRootPath = _imageRootProvider();
var resolvedCuts = ResolveCuts(template, station);
var hasEndScene = KarismaSceneResolver.HasEndScene(template, imageRootPath);
var regionTargets = await _dataRefreshGate
.ResolveScheduleRegionTargetsAsync(queueItem, template, station, cancellationToken)
.ConfigureAwait(false);
@@ -320,8 +321,9 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = queueItem.SelectionRegionLabel;
foreach (var cut in resolvedCuts)
for (var cutIndex = 0; cutIndex < resolvedCuts.Count; cutIndex++)
{
var cut = ResolveScheduledCut(resolvedCuts[cutIndex], hasEndScene, cutIndex == resolvedCuts.Count - 1);
queueItem.State = ScheduleQueueItemState.Sending;
RefreshQueueMarkers();
@@ -336,7 +338,8 @@ public sealed class ChannelScheduleEngine
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_advanceSignal = signal;
var delayTask = Task.Delay(TimeSpan.FromSeconds(cut.DurationSeconds), cancellationToken);
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
}
@@ -347,8 +350,9 @@ public sealed class ChannelScheduleEngine
return;
}
foreach (var regionTarget in regionTargets)
for (var regionIndex = 0; regionIndex < regionTargets.Count; regionIndex++)
{
var regionTarget = regionTargets[regionIndex];
ElectionDataSnapshot snapshot;
try
{
@@ -376,7 +380,8 @@ public sealed class ChannelScheduleEngine
}
queueItem.CurrentRegionLabel = regionTarget.DisplayName;
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot);
var isLastRegion = regionIndex == regionTargets.Count - 1;
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastRegion);
queueItem.TotalCuts = playbackCuts.Count;
foreach (var cut in playbackCuts)
@@ -395,7 +400,8 @@ public sealed class ChannelScheduleEngine
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_advanceSignal = signal;
var delayTask = Task.Delay(TimeSpan.FromSeconds(cut.DurationSeconds), cancellationToken);
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
}
@@ -410,6 +416,11 @@ public sealed class ChannelScheduleEngine
private static bool ShouldUseAggregateScheduleSnapshot(FormatTemplateDefinition template)
{
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return true;
}
if (template.RecommendedChannel == BroadcastChannel.Bottom)
{
return string.Equals(template.Name, "사전투표율", StringComparison.Ordinal) ||
@@ -423,17 +434,18 @@ public sealed class ChannelScheduleEngine
private static IReadOnlyList<FormatCutDefinition> ResolvePlaybackCuts(
FormatTemplateDefinition template,
IReadOnlyList<FormatCutDefinition> baseCuts,
ElectionDataSnapshot snapshot)
ElectionDataSnapshot snapshot,
bool useEndSceneOnLastCut)
{
if (!IsCareerTemplate(template) || baseCuts.Count == 0)
{
return baseCuts;
return ApplyEndSceneToLastCut(baseCuts, useEndSceneOnLastCut);
}
var candidateCount = snapshot.Candidates.Count;
if (candidateCount <= 1)
{
return baseCuts;
return ApplyEndSceneToLastCut(baseCuts, useEndSceneOnLastCut);
}
var playbackCuts = new List<FormatCutDefinition>(baseCuts.Count * candidateCount);
@@ -445,12 +457,46 @@ public sealed class ChannelScheduleEngine
{
Name = $"{baseCut.Name} #{candidateIndex + 1}",
DurationSeconds = baseCut.DurationSeconds,
CandidateStartIndex = candidateIndex
CandidateStartIndex = candidateIndex,
UseEndScene = baseCut.UseEndScene
});
}
}
return playbackCuts;
return ApplyEndSceneToLastCut(playbackCuts, useEndSceneOnLastCut);
}
private static IReadOnlyList<FormatCutDefinition> ApplyEndSceneToLastCut(
IReadOnlyList<FormatCutDefinition> cuts,
bool useEndSceneOnLastCut)
{
if (!useEndSceneOnLastCut || cuts.Count == 0)
{
return cuts;
}
var result = cuts.ToArray();
result[^1] = ResolveScheduledCut(result[^1], hasEndScene: true, useEndScene: true);
return result;
}
private static FormatCutDefinition ResolveScheduledCut(
FormatCutDefinition cut,
bool hasEndScene,
bool useEndScene)
{
if (!hasEndScene || !useEndScene || cut.UseEndScene)
{
return cut;
}
return new FormatCutDefinition
{
Name = cut.Name,
DurationSeconds = cut.DurationSeconds,
CandidateStartIndex = cut.CandidateStartIndex,
UseEndScene = true
};
}
private static bool IsCareerTemplate(FormatTemplateDefinition template)

View File

@@ -6,17 +6,7 @@ namespace Tornado3_2026Election.Services;
internal static class CutAppearancePolicyCatalog
{
private static readonly IReadOnlyDictionary<string, IReadOnlySet<string>> DefaultAppearanceSectionsByTemplate =
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal)
{
["1-2위_ani_광역단체장"] = CreateSectionSet(
"정당판",
"정당바",
"득표수바",
"정당원",
"정당색",
"정당명",
"득표율")
};
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal);
public static bool UsesTemplateDefaultAppearance(string templateName, string sectionName)
{

View File

@@ -114,7 +114,6 @@ public sealed class FormatCatalogService
"사전_역대당선자_교육감",
"사전_역대당선자_기초단체장",
"사전_역대투표율",
"사전_역대투표율_loop",
"사전_역대투표율_5760",
"역대시도판세_광역단체장",
"역대시도판세_기초단체장",
@@ -166,17 +165,20 @@ public sealed class FormatCatalogService
var isAvailableInBothPhases = IsAvailableInBothPhases(baseName);
var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(baseName);
var sceneResolution = TryReadSceneResolution(relativeFolder, baseName, t3CutPath);
var recommendedChannel = ResolveRecommendedChannel(channel, baseName, sceneResolution);
yield return new FormatTemplateDefinition
{
Id = Path.Combine(relativeFolder, baseName),
Name = baseName,
Description = $"{relativeFolder} 컷",
RecommendedChannel = ResolveRecommendedChannel(channel, baseName, sceneResolution),
RecommendedChannel = recommendedChannel,
RequiresImage = false,
SupportsPreElection = isAvailableInBothPhases || isPreElectionOnlyFormat,
SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat,
RequiresCandidateData = !isPreElectionOnlyFormat && !IsHistoricalPreElectionWinnerFormat(baseName),
RequiresCandidateData = !isPreElectionOnlyFormat &&
!IsHistoricalPreElectionWinnerFormat(baseName) &&
!ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(baseName),
LoopMode = LoopMode.None,
SceneWidth = sceneResolution?.Width,
SceneHeight = sceneResolution?.Height,
@@ -185,7 +187,10 @@ public sealed class FormatCatalogService
new FormatCutDefinition
{
Name = baseName,
DurationSeconds = defaultCutDurationSeconds
DurationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
defaultCutDurationSeconds,
recommendedChannel,
baseName)
}
]
};
@@ -228,6 +233,8 @@ public sealed class FormatCatalogService
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_L_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760"),
[Path.Combine("Elect2026_Normal_민방", "민방_타이틀_L")] = Path.Combine("Elect2026_Normal_민방", "민방_타이틀_1920"),
[Path.Combine("Elect2026_Normal_민방", "민방_타이틀_L_nologo")] = Path.Combine("Elect2026_Normal_민방", "민방_타이틀_5760_nologo"),
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_loop")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율"),
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760_loop")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L_1")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
[Path.Combine("Elect2026_Normal_민방", "이시각1위_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "이시각1위_광역단체장_HD"),
@@ -308,12 +315,6 @@ public sealed class FormatCatalogService
private static string ResolveT3CutPath(string? configuredT3CutPath)
{
var normalizedPath = TornadoPathResolver.NormalizeConfiguredPath(configuredT3CutPath);
if (!string.IsNullOrWhiteSpace(normalizedPath))
{
return normalizedPath;
}
return TornadoPathResolver.GetDefaultT3CutPath();
}
}

View File

@@ -0,0 +1,7 @@
namespace Tornado3_2026Election.Services;
public readonly record struct KarismaChartCellUpdate(
string ObjectName,
int Row,
int Column,
float Value);

View File

@@ -0,0 +1,10 @@
using KAsyncEngineLib;
namespace Tornado3_2026Election.Services;
public readonly record struct KarismaPositionUpdate(
string ObjectName,
float X,
float Y,
float Z,
eKVectorType VectorType);

View File

@@ -6,13 +6,22 @@ namespace Tornado3_2026Election.Services;
internal static class KarismaSceneResolver
{
public static KarismaResolvedScene ResolveScene(FormatTemplateDefinition template, string t3CutPath, bool useLoop)
public static KarismaResolvedScene ResolveScene(
FormatTemplateDefinition template,
string t3CutPath,
bool useLoop,
bool useEnd = false)
{
var baseScenePath = Path.Combine(t3CutPath, template.Id + ".tscn");
var loopScenePath = Path.Combine(t3CutPath, template.Id + "_loop.tscn");
var endScenePath = Path.Combine(t3CutPath, template.Id + "_END.tscn");
string selectedPath;
if (useLoop && File.Exists(loopScenePath))
if (useEnd && File.Exists(endScenePath))
{
selectedPath = endScenePath;
}
else if (useLoop && File.Exists(loopScenePath))
{
selectedPath = loopScenePath;
}
@@ -33,6 +42,11 @@ internal static class KarismaSceneResolver
selectedPath,
template.Id.Replace('\\', '_').Replace('/', '_'));
}
public static bool HasEndScene(FormatTemplateDefinition template, string t3CutPath)
{
return File.Exists(Path.Combine(t3CutPath, template.Id + "_END.tscn"));
}
}
internal readonly record struct KarismaResolvedScene(string Path, string Alias);

View File

@@ -253,7 +253,8 @@ public sealed class KarismaSceneVariableCatalog
{
return variableName.StartsWith("득표율", StringComparison.Ordinal) ||
variableName.StartsWith("투표율", StringComparison.Ordinal) ||
variableName.StartsWith("전국투표율", StringComparison.Ordinal);
variableName.StartsWith("전국투표율", StringComparison.Ordinal) ||
variableName.StartsWith("의석수", StringComparison.Ordinal);
}
private static IEnumerable<string> FindDiscoveryReportPaths()

View File

@@ -30,9 +30,7 @@ public sealed class KarismaThumbnailGeneratorService
VideoWallLayoutPreset videoWallLayoutPreset,
CancellationToken cancellationToken)
{
var t3CutPath = string.IsNullOrWhiteSpace(configuredT3CutPath)
? TornadoPathResolver.GetDefaultT3CutPath()
: TornadoPathResolver.NormalizeConfiguredPath(configuredT3CutPath);
var t3CutPath = TornadoPathResolver.GetDefaultT3CutPath();
if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
{

View File

@@ -6,6 +6,7 @@ using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using KAsyncEngineLib;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
@@ -15,6 +16,19 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private const int DefaultKarismaPort = 30001;
private const int DefaultCandidateSlotClearCount = 8;
private const int DefaultTurnoutSlotClearCount = 6;
private const string HistoricalTurnoutChartObjectName = "차트01";
private const string HistoricalTurnoutCircleObjectPrefix = "투표율원";
private const int HistoricalTurnoutChartCellRowCount = 8;
private const float HistoricalTurnoutCounterBaseX = -0.37133408f;
private const float HistoricalTurnoutCounterBaseY = 24.838575f;
private const float HistoricalTurnoutCounterBaseZ = 0f;
private const float HistoricalTurnoutCircleBaseY = -43.72818f;
private const float HistoricalTurnoutCircleBaseZ = 0f;
// Scene-local Y scale: 100% stays at the template's top label position; lower rates move downward.
private const float HistoricalTurnoutCounterYUnitsPerPercent = 5.1f;
private const float HistoricalTurnoutCircleYUnitsPerPercent = 6.32f;
private const int DefaultCouncilSeatSlotCount = 6;
private const string CouncilSeatCandidateCodePrefix = "SEAT:";
private static readonly string[] CandidateSlotVariablePrefixes =
[
"순위",
@@ -34,6 +48,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
"정당심볼",
"그룹"
];
private static readonly string[] CouncilSeatObjectSuffixes = ["A", "B", "C"];
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
private static readonly IReadOnlyDictionary<string, string> FullRegionNames =
@@ -66,6 +81,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings;
private readonly string _connectionTarget;
private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new();
private readonly Dictionary<BroadcastChannel, bool> _pendingEndScenes = new();
private readonly Dictionary<BroadcastChannel, bool> _channelOnAir = new();
private TornadoConnectionState _state = TornadoConnectionState.Idle;
private bool _disposed;
@@ -137,20 +153,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
logService.Info($"Karisma adapter using default port {DefaultKarismaPort}.");
}
var configuredT3CutPath = t3CutPathProvider();
var t3CutPath = string.IsNullOrWhiteSpace(configuredT3CutPath)
? TornadoPathResolver.GetDefaultT3CutPath()
: TornadoPathResolver.NormalizeConfiguredPath(configuredT3CutPath);
if (!string.Equals(configuredT3CutPath, t3CutPath, StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(t3CutPath))
{
logService.Info($"Karisma adapter normalized T3_Cut path to '{t3CutPath}'.");
}
var t3CutPath = TornadoPathResolver.GetDefaultT3CutPath();
logService.Info($"Karisma adapter using fixed T3_Cut path '{t3CutPath}'.");
if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
{
logService.Warning($"Karisma adapter disabled: set a valid T3_Cut path in Settings. current='{configuredT3CutPath}'");
logService.Warning($"Karisma adapter disabled: fixed T3_Cut path does not exist. path='{t3CutPath}'");
adapter = new MockTornado3Adapter(logService);
return false;
}
@@ -194,10 +202,16 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
{
var binding = ResolveBinding(channel);
var t3CutPath = ResolveT3CutPath();
var resolvedScene = KarismaSceneResolver.ResolveScene(template, t3CutPath, IsChannelOnAir(channel));
var resolvedScene = KarismaSceneResolver.ResolveScene(
template,
t3CutPath,
IsChannelOnAir(channel),
cut.UseEndScene);
var sceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path);
var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath, sceneVariables);
var counterNumberKeys = BuildCounterNumberKeyUpdates(template, cut, snapshot, sceneVariables);
var chartCellUpdates = BuildChartCellUpdates(template, snapshot, sceneVariables);
var positionUpdates = BuildPositionUpdates(template, snapshot, sceneVariables);
var styleColorUpdates = BuildStyleColorUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
var judgementVisibilityUpdates = BuildJudgementVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
var historicalWinnerVisibilityUpdates = BuildHistoricalWinnerVisibilityUpdates(template, cut, snapshot, sceneVariables);
@@ -266,6 +280,8 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
.ToArray(),
overriddenValues,
overriddenCounterNumberKeys,
chartCellUpdates,
positionUpdates,
overriddenStyleColorUpdates,
overriddenJudgementVisibilityUpdates.ShowAfterValue
.Concat(overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue)
@@ -274,6 +290,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
cancellationToken).ConfigureAwait(false);
_pendingScenes[channel] = resolvedScene.Alias;
_pendingEndScenes[channel] = cut.UseEndScene;
_logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}");
},
$"apply {template.Id}/{cut.Name}",
@@ -306,7 +323,8 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
{
var binding = ResolveBinding(channel);
await _manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, cancellationToken).ConfigureAwait(false);
_channelOnAir[channel] = true;
var isEndScene = _pendingEndScenes.TryGetValue(channel, out var pendingEndScene) && pendingEndScene;
_channelOnAir[channel] = !isEndScene;
State = TornadoConnectionState.OnAir;
},
$"take {channel}",
@@ -321,12 +339,36 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
var binding = ResolveBinding(channel);
await _manager.PlayOutAsync(binding.OutputChannelIndex, binding.LayerNo, cutOut: false, cancellationToken).ConfigureAwait(false);
_channelOnAir[channel] = false;
_pendingEndScenes[channel] = false;
State = TornadoConnectionState.Idle;
},
$"out {channel}",
cancellationToken).ConfigureAwait(false);
}
public async Task SavePendingSceneImageAsync(
BroadcastChannel channel,
string fileName,
int width,
int height,
int frame,
CancellationToken cancellationToken)
{
await ExecuteAsync(
async () =>
{
if (!_pendingScenes.TryGetValue(channel, out var sceneAlias))
{
throw new InvalidOperationException($"[{channel}] No Karisma scene pending for image capture.");
}
await _manager.SaveSceneImageAsync(sceneAlias, fileName, width, height, frame, cancellationToken)
.ConfigureAwait(false);
},
$"save image {channel}",
cancellationToken).ConfigureAwait(false);
}
public void Dispose()
{
if (_disposed)
@@ -365,14 +407,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private string ResolveT3CutPath()
{
var configuredPath = _t3CutPathProvider();
var path = string.IsNullOrWhiteSpace(configuredPath)
? TornadoPathResolver.GetDefaultT3CutPath()
: TornadoPathResolver.NormalizeConfiguredPath(configuredPath);
var path = TornadoPathResolver.GetDefaultT3CutPath();
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
{
throw new DirectoryNotFoundException("T3_Cut path is not configured or does not exist.");
throw new DirectoryNotFoundException($"Fixed T3_Cut path does not exist: {path}");
}
return path;
@@ -589,9 +628,279 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
ApplyCareerPromiseValues(values, template, t3CutPath, templateFolderPath, orderedCandidates.FirstOrDefault());
}
ApplyCouncilSeatTableValues(values, template, snapshot, t3CutPath, templateFolderPath, sceneVariables);
return FilterValuesForScene(values, sceneVariables);
}
private static void ApplyCouncilSeatTableValues(
IDictionary<string, string> values,
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
string t3CutPath,
string templateFolderPath,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
if (!ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return;
}
var seatRows = BuildCouncilSeatSummaries(snapshot);
var slotCount = ResolveCouncilSeatSlotCount(sceneVariables);
for (var slot = 1; slot <= slotCount; slot++)
{
var partyBarPath = slot <= seatRows.Length
? ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, seatRows[slot - 1].ColorParty, PartyAssetKind.Bar)
: string.Empty;
ApplyCouncilSeatSlotValue(values, sceneVariables, "정당바", slot, partyBarPath);
}
}
private static CouncilSeatSummary[] BuildCouncilSeatSummaries(ElectionDataSnapshot snapshot)
{
var syntheticSeatRows = snapshot.Candidates
.Where(IsCouncilSeatSummaryCandidate)
.ToArray();
if (syntheticSeatRows.Length > 0)
{
return syntheticSeatRows
.GroupBy(candidate => ResolveCouncilSeatPartyKey(candidate), StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var first = group.First();
var districtSeats = 0;
var proportionalSeats = 0;
var explicitTotalSeats = 0;
foreach (var candidate in group)
{
var seatCount = Math.Max(0, candidate.VoteCount);
switch (ResolveCouncilSeatSource(candidate))
{
case CouncilSeatSource.District:
districtSeats += seatCount;
break;
case CouncilSeatSource.Proportional:
proportionalSeats += seatCount;
break;
default:
explicitTotalSeats += seatCount;
break;
}
}
if (explicitTotalSeats > 0 && districtSeats + proportionalSeats == 0)
{
districtSeats = explicitTotalSeats;
}
return new CouncilSeatSummary(
ResolveCouncilSeatParty(first),
ResolveCouncilSeatColorParty(first),
districtSeats,
proportionalSeats);
})
.Where(row => row.TotalSeatCount > 0)
.OrderByDescending(row => row.TotalSeatCount)
.ThenBy(row => row.Party, StringComparer.Ordinal)
.ToArray();
}
return snapshot.Candidates
.Where(candidate => CountsAsCouncilSeat(candidate.EffectiveJudgement))
.GroupBy(candidate => ResolveCouncilSeatPartyKey(candidate), StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var first = group.First();
var districtSeats = group.Count();
return new CouncilSeatSummary(
ResolveCouncilSeatParty(first),
ResolveCouncilSeatColorParty(first),
districtSeats,
ProportionalSeatCount: 0);
})
.Where(row => row.TotalSeatCount > 0)
.OrderByDescending(row => row.TotalSeatCount)
.ThenBy(row => row.Party, StringComparer.Ordinal)
.ToArray();
}
private static bool IsCouncilSeatSummaryCandidate(CandidateEntry candidate)
{
return candidate.CandidateCode.StartsWith(CouncilSeatCandidateCodePrefix, StringComparison.OrdinalIgnoreCase);
}
private static bool CountsAsCouncilSeat(CandidateJudgement judgement)
{
return judgement is CandidateJudgement.Leading or
CandidateJudgement.Confirmed or
CandidateJudgement.Elected or
CandidateJudgement.ElectedInProgress or
CandidateJudgement.UnopposedElected or
CandidateJudgement.ElectedAfterCountComplete;
}
private static string ResolveCouncilSeatPartyKey(CandidateEntry candidate)
{
var party = ResolveCouncilSeatParty(candidate);
return string.IsNullOrWhiteSpace(party) ? "무소속" : party.Trim();
}
private static string ResolveCouncilSeatParty(CandidateEntry candidate)
{
if (!string.IsNullOrWhiteSpace(candidate.Party))
{
return candidate.Party.Trim();
}
return string.IsNullOrWhiteSpace(candidate.EffectiveColorParty)
? "무소속"
: candidate.EffectiveColorParty.Trim();
}
private static string ResolveCouncilSeatColorParty(CandidateEntry candidate)
{
return string.IsNullOrWhiteSpace(candidate.EffectiveColorParty)
? ResolveCouncilSeatParty(candidate)
: candidate.EffectiveColorParty.Trim();
}
private static CouncilSeatSource ResolveCouncilSeatSource(CandidateEntry candidate)
{
if (!candidate.CandidateCode.StartsWith(CouncilSeatCandidateCodePrefix, StringComparison.OrdinalIgnoreCase))
{
return CouncilSeatSource.District;
}
var suffix = candidate.CandidateCode.Substring(CouncilSeatCandidateCodePrefix.Length);
if (suffix.StartsWith("D:", StringComparison.OrdinalIgnoreCase) ||
suffix.StartsWith("DISTRICT:", StringComparison.OrdinalIgnoreCase) ||
suffix.StartsWith("A:", StringComparison.OrdinalIgnoreCase))
{
return CouncilSeatSource.District;
}
if (suffix.StartsWith("P:", StringComparison.OrdinalIgnoreCase) ||
suffix.StartsWith("PROPORTIONAL:", StringComparison.OrdinalIgnoreCase) ||
suffix.StartsWith("B:", StringComparison.OrdinalIgnoreCase))
{
return CouncilSeatSource.Proportional;
}
return CouncilSeatSource.Total;
}
private static int ResolveCouncilSeatSlotCount(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
var maxSlot = 0;
foreach (var variableName in sceneVariables.Keys)
{
if ((TryParseCouncilSeatSlot(variableName, "의석수", out var seatSlot) ||
TryParseCouncilSeatSlot(variableName, "정당바", out seatSlot)) &&
seatSlot > maxSlot)
{
maxSlot = seatSlot;
}
}
return maxSlot > 0 ? maxSlot : DefaultCouncilSeatSlotCount;
}
private static void ApplyCouncilSeatSlotValue(
IDictionary<string, string> values,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
string prefix,
int slot,
string value)
{
var matched = false;
foreach (var variableName in sceneVariables.Keys)
{
if (!TryParseCouncilSeatSlot(variableName, prefix, out var parsedSlot) ||
parsedSlot != slot)
{
continue;
}
values[variableName] = value;
matched = true;
}
if (matched || sceneVariables.Count > 0)
{
return;
}
foreach (var suffix in CouncilSeatObjectSuffixes)
{
values[$"{prefix}{slot:00}{suffix}"] = value;
}
}
private static bool TryParseCouncilSeatSlot(string variableName, string prefix, out int slot)
{
return TryParseCouncilSeatSlotColumn(variableName, prefix, out slot, out _);
}
private static bool TryParseCouncilSeatSlotColumn(
string variableName,
string prefix,
out int slot,
out CouncilSeatColumn column)
{
slot = 0;
column = CouncilSeatColumn.Total;
if (string.IsNullOrWhiteSpace(variableName) ||
!variableName.StartsWith(prefix, StringComparison.Ordinal))
{
return false;
}
var suffix = variableName.Substring(prefix.Length);
var digitLength = 0;
while (digitLength < suffix.Length && char.IsDigit(suffix[digitLength]))
{
digitLength++;
}
if (digitLength < 2)
{
return false;
}
var slotText = digitLength >= 4
? suffix.Substring(digitLength - 2, 2)
: suffix.Substring(0, 2);
if (!int.TryParse(slotText, NumberStyles.None, CultureInfo.InvariantCulture, out slot) || slot <= 0)
{
return false;
}
column = ResolveCouncilSeatColumn(suffix.Substring(digitLength));
return true;
}
private static CouncilSeatColumn ResolveCouncilSeatColumn(string suffix)
{
return suffix.Trim().ToUpperInvariant() switch
{
"A" => CouncilSeatColumn.District,
"B" => CouncilSeatColumn.Proportional,
_ => CouncilSeatColumn.Total
};
}
private static int ResolveCouncilSeatColumnCount(CouncilSeatSummary summary, CouncilSeatColumn column)
{
return column switch
{
CouncilSeatColumn.District => summary.DistrictSeatCount,
CouncilSeatColumn.Proportional => summary.ProportionalSeatCount,
_ => summary.TotalSeatCount
};
}
private static IReadOnlyList<KarismaStyleColorUpdate> BuildStyleColorUpdates(
FormatTemplateDefinition template,
FormatCutDefinition cut,
@@ -780,7 +1089,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return BuildTurnoutCounterNumberKeyUpdates(snapshot, sceneVariables);
}
if (!IsAnimatedTemplate(template))
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return BuildCouncilSeatCounterNumberKeyUpdates(snapshot, sceneVariables);
}
if (!IsAnimatedTemplate(template) && !HasVoteRateCounterVariables(sceneVariables))
{
return Array.Empty<KarismaCounterNumberKeyUpdate>();
}
@@ -812,6 +1126,69 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates;
}
private static bool HasVoteRateCounterVariables(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
return sceneVariables.Values.Any(variable =>
variable.Kind == KarismaSceneVariableKind.Counter &&
IsVoteRateVariable(variable.Name));
}
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildCouncilSeatCounterNumberKeyUpdates(
ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
var seatRows = BuildCouncilSeatSummaries(snapshot);
var slotCount = ResolveCouncilSeatSlotCount(sceneVariables);
if (slotCount <= 0)
{
return Array.Empty<KarismaCounterNumberKeyUpdate>();
}
var updates = new List<KarismaCounterNumberKeyUpdate>(slotCount * CouncilSeatObjectSuffixes.Length);
for (var slot = 1; slot <= slotCount; slot++)
{
var seatRow = slot <= seatRows.Length ? seatRows[slot - 1] : default;
AddCouncilSeatCounterSlotUpdates(updates, sceneVariables, slot, seatRow);
}
return updates;
}
private static void AddCouncilSeatCounterSlotUpdates(
ICollection<KarismaCounterNumberKeyUpdate> updates,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
int slot,
CouncilSeatSummary seatRow)
{
var matched = false;
foreach (var variableName in sceneVariables.Keys)
{
if (!TryParseCouncilSeatSlotColumn(variableName, "의석수", out var parsedSlot, out var column) ||
parsedSlot != slot)
{
continue;
}
updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, ResolveCouncilSeatColumnCount(seatRow, column)));
matched = true;
}
if (matched || sceneVariables.Count > 0)
{
return;
}
updates.Add(new KarismaCounterNumberKeyUpdate($"의석수{slot:00}", 1, seatRow.TotalSeatCount));
foreach (var suffix in CouncilSeatObjectSuffixes)
{
var column = ResolveCouncilSeatColumn(suffix);
updates.Add(new KarismaCounterNumberKeyUpdate(
$"의석수{slot:00}{suffix}",
1,
ResolveCouncilSeatColumnCount(seatRow, column)));
}
}
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildTurnoutCounterNumberKeyUpdates(
ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
@@ -857,6 +1234,98 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates;
}
private static IReadOnlyList<KarismaChartCellUpdate> BuildChartCellUpdates(
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
if (!IsHistoricalTurnoutTemplate(template.Name))
{
return Array.Empty<KarismaChartCellUpdate>();
}
if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(HistoricalTurnoutChartObjectName))
{
return Array.Empty<KarismaChartCellUpdate>();
}
var rates = ResolveHistoricalTurnoutSlotRates(snapshot);
var updates = new List<KarismaChartCellUpdate>(HistoricalTurnoutChartCellRowCount);
updates.Add(new KarismaChartCellUpdate(HistoricalTurnoutChartObjectName, 0, 0, 0f));
for (var row = 0; row < HistoricalTurnoutChartCellRowCount; row++)
{
if (row is 0 or 7)
{
continue;
}
var sourceIndex = row - 1;
var value = (float)Math.Round(rates[sourceIndex], 1, MidpointRounding.AwayFromZero);
updates.Add(new KarismaChartCellUpdate(
HistoricalTurnoutChartObjectName,
row,
0,
value));
}
updates.Add(new KarismaChartCellUpdate(HistoricalTurnoutChartObjectName, 7, 0, 100f));
return updates;
}
private static IReadOnlyList<KarismaPositionUpdate> BuildPositionUpdates(
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
if (!IsHistoricalTurnoutTemplate(template.Name))
{
return Array.Empty<KarismaPositionUpdate>();
}
var rates = ResolveHistoricalTurnoutSlotRates(snapshot);
var updates = new List<KarismaPositionUpdate>(rates.Length * 2);
var shouldMoveCircles = ShouldMoveHistoricalTurnoutCircles(template);
for (var slot = 1; slot <= rates.Length; slot++)
{
var counterName = $"투표율{slot:00}";
if (sceneVariables.Count == 0 || sceneVariables.ContainsKey(counterName))
{
updates.Add(new KarismaPositionUpdate(
counterName,
HistoricalTurnoutCounterBaseX,
ResolveHistoricalTurnoutCounterY(rates[slot - 1]),
HistoricalTurnoutCounterBaseZ,
eKVectorType.VECTOR_TYPE_Y));
}
if (shouldMoveCircles)
{
var circleName = $"{HistoricalTurnoutCircleObjectPrefix}{slot:00}";
updates.Add(new KarismaPositionUpdate(
circleName,
0f,
ResolveHistoricalTurnoutCircleY(rates[slot - 1]),
HistoricalTurnoutCircleBaseZ,
eKVectorType.VECTOR_TYPE_Y));
}
}
return updates;
}
private static bool ShouldMoveHistoricalTurnoutCircles(FormatTemplateDefinition template)
{
if (template.Name.Contains("_5760", StringComparison.Ordinal) ||
template.Name.EndsWith("_L", StringComparison.Ordinal) ||
template.Name.EndsWith("_L_1", StringComparison.Ordinal))
{
return false;
}
return true;
}
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildHistoricalTurnoutCounterNumberKeyUpdates(
ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
@@ -909,6 +1378,37 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates;
}
private static double[] ResolveHistoricalTurnoutSlotRates(ElectionDataSnapshot snapshot)
{
var rates = new double[DefaultTurnoutSlotClearCount];
foreach (var turnout in snapshot.HistoricalTurnoutHistory)
{
var slot = turnout.ElectionOrder - 2;
if (slot is < 1 or > DefaultTurnoutSlotClearCount)
{
continue;
}
rates[slot - 1] = Math.Round(turnout.TurnoutRate, 1, MidpointRounding.AwayFromZero);
}
return rates;
}
private static float ResolveHistoricalTurnoutCounterY(double turnoutRate)
{
var clampedRate = Math.Clamp(turnoutRate, 0d, 100d);
return HistoricalTurnoutCounterBaseY -
(float)((100d - clampedRate) * HistoricalTurnoutCounterYUnitsPerPercent);
}
private static float ResolveHistoricalTurnoutCircleY(double turnoutRate)
{
var clampedRate = Math.Clamp(turnoutRate, 0d, 100d);
return HistoricalTurnoutCircleBaseY -
(float)((100d - clampedRate) * HistoricalTurnoutCircleYUnitsPerPercent);
}
private void LogCutDebugSummary(
BroadcastChannel channel,
FormatTemplateDefinition template,
@@ -1393,12 +1893,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string.Equals(variableName, "전국투표율", StringComparison.Ordinal) ||
MatchesIndexedVariable(variableName, "득표율") ||
MatchesIndexedVariable(variableName, "투표율") ||
MatchesIndexedVariable(variableName, "전국투표율");
MatchesIndexedVariable(variableName, "전국투표율") ||
TryParseCouncilSeatSlot(variableName, "의석수", out _);
}
private static bool IsImageValueVariable(string variableName)
{
return IsJudgementVariableName(variableName) ||
TryParseCouncilSeatSlot(variableName, "정당바", out _) ||
MatchesIndexedVariable(variableName, "후보사진") ||
MatchesIndexedVariable(variableName, "득표수바") ||
MatchesIndexedVariable(variableName, "정당바") ||
@@ -1910,6 +2412,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
MatchesIndexedVariable(variableName, "기호텍스트") ||
MatchesIndexedVariable(variableName, "후보명") ||
MatchesIndexedVariable(variableName, "정당명") ||
TryParseCouncilSeatSlot(variableName, "의석수", out _) ||
MatchesIndexedVariable(variableName, "득표수") ||
MatchesIndexedVariable(variableName, "득표율") ||
MatchesIndexedVariable(variableName, "표차") ||
@@ -1917,6 +2420,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
MatchesIndexedVariable(variableName, "유확당") ||
MatchesIndexedVariable(variableName, "후보사진") ||
MatchesIndexedVariable(variableName, "득표수바") ||
TryParseCouncilSeatSlot(variableName, "정당바", out _) ||
MatchesIndexedVariable(variableName, "정당바") ||
MatchesIndexedVariable(variableName, "정당판") ||
MatchesIndexedVariable(variableName, "정당원") ||
@@ -2531,12 +3035,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
yield break;
}
if (normalized is "민주자유당" or "신한국당" or "한나라당" or "새누리당" or "자유한국당")
if (normalized is "국힘" or "미래통합당" or "민주자유당" or "신한국당" or "한나라당" or "새누리당" or "자유한국당")
{
yield return "국민의힘";
}
if (normalized is "민주당" or "새천년민주당" or "열린우리당" or "새정치민주연합")
if (normalized is "민주당" or "민주당1991" or "민주당2000" or "민주당2008" or "새정치국민회의" or "새천년민주당" or "열린민주당" or "열린우리당" or "민주통합당" or "새정치민주연합")
{
yield return "더불어민주당";
}
@@ -2705,6 +3209,29 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
Group
}
private enum CouncilSeatColumn
{
District,
Proportional,
Total
}
private enum CouncilSeatSource
{
District,
Proportional,
Total
}
private readonly record struct CouncilSeatSummary(
string Party,
string ColorParty,
int DistrictSeatCount,
int ProportionalSeatCount)
{
public int TotalSeatCount => DistrictSeatCount + ProportionalSeatCount;
}
private readonly record struct KarismaChannelBinding(int OutputChannelIndex, int LayerNo);
}

View File

@@ -523,12 +523,12 @@ internal static class PartyColorCatalog
yield break;
}
if (normalized is "민주자유당" or "신한국당" or "한나라당" or "새누리당" or "자유한국당")
if (normalized is "국힘" or "미래통합당" or "민주자유당" or "신한국당" or "한나라당" or "새누리당" or "자유한국당")
{
yield return "국민의힘";
}
if (normalized is "민주당" or "새천년민주당" or "열린우리당" or "새정치민주연합")
if (normalized is "민주당" or "민주당1991" or "민주당2000" or "민주당2008" or "새정치국민회의" or "새천년민주당" or "열린민주당" or "열린우리당" or "민주통합당" or "새정치민주연합")
{
yield return "더불어민주당";
}

View File

@@ -451,6 +451,15 @@ public sealed class PreElectionHistoryService
return Path.Combine(current.FullName, RelativeAssetPath);
}
var repositoryProjectPath = Path.Combine(
current.FullName,
"Tornado3_2026Election",
"Tornado3_2026Election.csproj");
if (File.Exists(repositoryProjectPath))
{
return Path.Combine(current.FullName, "Tornado3_2026Election", RelativeAssetPath);
}
current = current.Parent;
}

View File

@@ -13,7 +13,11 @@ namespace Tornado3_2026Election.Services;
public sealed class SbsElectionApiClient : IDisposable
{
private static readonly Uri BaseUri = new("http://202.31.153.141:8421/");
private const string BasicApiBaseUrlEnvironmentVariable = "SBS_BASIC_API_BASE_URL";
private const string BasicApiModeEnvironmentVariable = "SBS_BASIC_API_MODE";
private static readonly TimeSpan BasicCouncilCountingCacheDuration = TimeSpan.FromMinutes(1);
private static readonly Uri LegacyBaseUri = new("http://202.31.153.141:8421/");
private static readonly Uri BasicApiBaseUri = ResolveBasicApiBaseUri();
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -23,9 +27,12 @@ public sealed class SbsElectionApiClient : IDisposable
private static readonly IReadOnlyDictionary<string, SbsElectionConfiguration> ElectionConfigurations =
new Dictionary<string, SbsElectionConfiguration>(StringComparer.Ordinal)
{
["광역단체장"] = new SbsElectionConfiguration(3, true),
["교육감"] = new SbsElectionConfiguration(11, false),
["기초단체장"] = new SbsElectionConfiguration(4, false)
["국회의원"] = new SbsElectionConfiguration(2, false, LegacyBaseUri, "gaepyo"),
["광역단체장"] = new SbsElectionConfiguration(3, true, LegacyBaseUri, "gaepyo"),
["교육감"] = new SbsElectionConfiguration(11, false, LegacyBaseUri, "gaepyo"),
["광역의원"] = new SbsElectionConfiguration(5, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment()),
["기초단체장"] = new SbsElectionConfiguration(4, false, LegacyBaseUri, "gaepyo"),
["기초의원"] = new SbsElectionConfiguration(6, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment())
};
private static readonly IReadOnlyDictionary<string, string> FullRegionNames =
@@ -50,14 +57,60 @@ public sealed class SbsElectionApiClient : IDisposable
["제주"] = "제주특별자치도"
};
private static readonly IReadOnlyDictionary<string, string> BasicApiSidoCodes =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["서울"] = "11",
["서울특별시"] = "11",
["부산"] = "26",
["부산광역시"] = "26",
["대구"] = "27",
["대구광역시"] = "27",
["인천"] = "28",
["인천광역시"] = "28",
["광주"] = "29",
["광주광역시"] = "29",
["전남광주"] = "29",
["광주전남"] = "29",
["대전"] = "30",
["대전광역시"] = "30",
["울산"] = "31",
["울산광역시"] = "31",
["세종"] = "36",
["세종특별자치시"] = "36",
["경기"] = "41",
["경기도"] = "41",
["충북"] = "43",
["충청북도"] = "43",
["충남"] = "44",
["충청남도"] = "44",
["전남"] = "29",
["전라남도"] = "29",
["경북"] = "47",
["경상북도"] = "47",
["경남"] = "48",
["경상남도"] = "48",
["제주"] = "50",
["제주도"] = "50",
["제주특별자치도"] = "50",
["강원"] = "52",
["강원도"] = "52",
["강원특별자치도"] = "52",
["전북"] = "53",
["전라북도"] = "53",
["전북특별자치도"] = "53"
};
private readonly HttpClient _httpClient;
private readonly bool _disposeHttpClient;
private IReadOnlyList<SbsRegionInfo>? _sidoRegions;
private readonly Dictionary<int, IReadOnlyList<SbsRegionInfo>> _districtRegions = new();
private readonly Dictionary<string, IReadOnlyList<SbsRegionInfo>> _districtRegions = new(StringComparer.Ordinal);
private readonly Dictionary<string, SbsCountingCacheEntry> _countingCache = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _countingCacheLock = new(1, 1);
public SbsElectionApiClient(HttpClient? httpClient = null)
{
_httpClient = httpClient ?? new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(10) };
_httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
_disposeHttpClient = httpClient is null;
}
@@ -71,7 +124,7 @@ public sealed class SbsElectionApiClient : IDisposable
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
{
throw new InvalidOperationException(
$"'{electionType}'은 현재 SBS API 실연동 범위에 없습니다. 현재는 광역단체장, 교육감, 기초단체장까지만 연결되어 있습니다.");
$"'{electionType}'은 현재 SBS API 실연동 범위에 없습니다. 현재는 국회의원, 광역단체장, 교육감, 광역의원, 기초단체장, 기초의원만 연결되어 있습니다.");
}
return phase switch
@@ -85,18 +138,47 @@ public sealed class SbsElectionApiClient : IDisposable
public async Task<IReadOnlyList<DistrictSelectionOption>> GetDistrictOptionsAsync(
string electionType,
CancellationToken cancellationToken)
{
return await GetDistrictOptionsAsync(
electionType,
Array.Empty<string>(),
cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<DistrictSelectionOption>> GetDistrictOptionsAsync(
string electionType,
IEnumerable<string> regionFilters,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
{
return Array.Empty<DistrictSelectionOption>();
}
var regions = await GetElectionDistrictRegionsAsync(configuration.SungerType, cancellationToken).ConfigureAwait(false);
return regions
.Select(region => CreateDistrictSelectionOption(configuration.SungerType, region))
.Where(option => !string.IsNullOrWhiteSpace(option.DisplayName))
.OrderBy(option => option.RegionName, StringComparer.Ordinal)
.ThenBy(option => option.DistrictName, StringComparer.Ordinal)
var scopedSidoCodes = ResolveBasicApiSidoCodes(regionFilters);
var regions = await GetElectionDistrictRegionsAsync(configuration, scopedSidoCodes, cancellationToken).ConfigureAwait(false);
var options = regions
.Select((region, index) => new
{
Region = region,
Index = index,
Option = CreateDistrictSelectionOption(configuration.SungerType, region)
})
.Where(item => !string.IsNullOrWhiteSpace(item.Option.DisplayName));
if (configuration.SungerType is 2 or 3 or 4 or 5 or 6)
{
return options
.OrderBy(item => item.Region.Order > 0 ? item.Region.Order : int.MaxValue)
.ThenBy(item => item.Index)
.Select(item => item.Option)
.ToArray();
}
return options
.OrderBy(item => item.Option.RegionName, StringComparer.Ordinal)
.ThenBy(item => item.Option.DistrictName, StringComparer.Ordinal)
.Select(item => item.Option)
.ToArray();
}
@@ -127,35 +209,33 @@ public sealed class SbsElectionApiClient : IDisposable
var districtMap = requestedDistricts.ToDictionary(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase);
var overviewItems = new List<(int Order, CountingOverviewItem Item)>();
foreach (var districtChunk in requestedDistricts.Chunk(24))
var countingItems = await GetCountingItemsForDistrictsAsync(
configuration,
requestedDistricts,
cancellationToken).ConfigureAwait(false);
foreach (var responseItem in countingItems)
{
var ids = string.Join(",", districtChunk.Select(district => district.DistrictCode));
var items = await GetArrayAsync<SbsCountingItem>(
$"gaepyo/{configuration.SungerType}/sungergus?ids={ids}",
cancellationToken).ConfigureAwait(false);
foreach (var item in items)
var item = responseItem.Item;
var regionId = item.Region?.Id;
if (string.IsNullOrWhiteSpace(regionId) ||
!districtMap.TryGetValue(regionId, out var districtOption) ||
!orderMap.TryGetValue(regionId, out var order))
{
var regionId = item.Region?.Id;
if (string.IsNullOrWhiteSpace(regionId) ||
!districtMap.TryGetValue(regionId, out var districtOption) ||
!orderMap.TryGetValue(regionId, out var order))
{
continue;
}
var totalVotes = Math.Max(0, item.Total?.Tupyosu ?? 0);
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes);
overviewItems.Add((order, new CountingOverviewItem(
DisplayName: districtOption.DisplayName,
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
CountedVotes: countedVotes,
TotalVotes: totalVotes,
UncountedVotes: Math.Max(0, uncountedVotes))));
continue;
}
var totalVotes = Math.Max(0, item.Total?.Tupyosu ?? 0);
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes);
overviewItems.Add((order, new CountingOverviewItem(
DisplayName: districtOption.DisplayName,
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
CountedVotes: countedVotes,
TotalVotes: totalVotes,
UncountedVotes: Math.Max(0, uncountedVotes))));
}
return overviewItems
@@ -164,6 +244,61 @@ public sealed class SbsElectionApiClient : IDisposable
.ToArray();
}
public async Task<IReadOnlyList<SbsElectionRefreshResult>> GetCountingSnapshotsAsync(
string electionType,
IReadOnlyList<DistrictSelectionOption> districts,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || districts.Count == 0)
{
return Array.Empty<SbsElectionRefreshResult>();
}
var requestedDistricts = districts
.Where(district => !string.IsNullOrWhiteSpace(district.DistrictCode))
.GroupBy(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToArray();
if (requestedDistricts.Length == 0)
{
return Array.Empty<SbsElectionRefreshResult>();
}
var orderMap = requestedDistricts
.Select((district, index) => new { district.DistrictCode, Index = index })
.ToDictionary(item => item.DistrictCode, item => item.Index, StringComparer.OrdinalIgnoreCase);
var districtMap = requestedDistricts.ToDictionary(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase);
var results = new List<(int Order, SbsElectionRefreshResult Result)>();
var countingItems = await GetCountingItemsForDistrictsAsync(
configuration,
requestedDistricts,
cancellationToken).ConfigureAwait(false);
foreach (var responseItem in countingItems)
{
var item = responseItem.Item;
var regionId = item.Region?.Id;
if (string.IsNullOrWhiteSpace(regionId) ||
!districtMap.TryGetValue(regionId, out var districtOption) ||
!orderMap.TryGetValue(regionId, out var order))
{
continue;
}
results.Add((order, CreateCountingRefreshResult(
configuration,
districtOption,
item,
responseItem.SourcePath)));
}
return results
.OrderBy(item => item.Order)
.Select(item => item.Result)
.ToArray();
}
public async Task<TurnoutOverviewResult> GetTurnoutOverviewAsync(
string electionType,
IReadOnlyList<DistrictSelectionOption> districts,
@@ -181,8 +316,13 @@ public sealed class SbsElectionApiClient : IDisposable
}
var requestedDistricts = districts
.Where(district => !string.IsNullOrWhiteSpace(district.DistrictCode))
.GroupBy(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase)
.Select(district => new
{
District = district,
RegionCode = ResolveTurnoutRegionCode(district)
})
.Where(item => !string.IsNullOrWhiteSpace(item.RegionCode))
.GroupBy(item => item.RegionCode, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToArray();
@@ -196,17 +336,18 @@ public sealed class SbsElectionApiClient : IDisposable
}
var orderMap = requestedDistricts
.Select((district, index) => new { district.DistrictCode, Index = index })
.ToDictionary(item => item.DistrictCode, item => item.Index, StringComparer.OrdinalIgnoreCase);
var districtMap = requestedDistricts.ToDictionary(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase);
.Select((item, index) => new { item.RegionCode, Index = index })
.ToDictionary(item => item.RegionCode, item => item.Index, StringComparer.OrdinalIgnoreCase);
var districtMap = requestedDistricts.ToDictionary(item => item.RegionCode, item => item.District, StringComparer.OrdinalIgnoreCase);
var turnoutItems = new List<(int Order, TurnoutOverviewItem Item)>();
var totalExpectedVotes = 0;
var turnoutVotes = 0;
foreach (var districtChunk in requestedDistricts.Chunk(24))
{
var ids = string.Join(",", districtChunk.Select(district => district.DistrictCode));
var ids = string.Join(",", districtChunk.Select(item => item.RegionCode));
var items = await GetArrayAsync<SbsTurnoutItem>(
configuration.BaseUri,
$"tupyo/{configuration.SungerType}/sidos?ids={ids}",
cancellationToken).ConfigureAwait(false);
@@ -250,8 +391,16 @@ public sealed class SbsElectionApiClient : IDisposable
DateTimeOffset.Now);
}
private static string ResolveTurnoutRegionCode(DistrictSelectionOption district)
{
return !string.IsNullOrWhiteSpace(district.ParentRegionCode)
? district.ParentRegionCode
: district.DistrictCode;
}
public void Dispose()
{
_countingCacheLock.Dispose();
if (_disposeHttpClient)
{
_httpClient.Dispose();
@@ -267,11 +416,12 @@ public sealed class SbsElectionApiClient : IDisposable
if (!configuration.SupportsPreElection)
{
throw new InvalidOperationException(
"선택한 선거 종류는 SBS API 문서 기준으로 사전 투표율 연동 대상이 아닙니다.");
"선택한 선거 종류는 SBS API 문서 기준으로 투표율 연동 대상이 아닙니다.");
}
var sido = await ResolveSidoRegionAsync(districtName, districtCode, cancellationToken).ConfigureAwait(false);
var sido = await ResolveSidoRegionAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false);
var items = await GetArrayAsync<SbsTurnoutItem>(
configuration.BaseUri,
$"tupyo/{configuration.SungerType}/sidos?ids={Uri.EscapeDataString(sido.Id)}",
cancellationToken).ConfigureAwait(false);
@@ -301,41 +451,210 @@ public sealed class SbsElectionApiClient : IDisposable
string districtCode,
CancellationToken cancellationToken)
{
if (CanQueryBasicCouncilByDistrictId(configuration) &&
!string.IsNullOrWhiteSpace(districtCode))
{
return await RefreshBasicCouncilCountingByDistrictIdAsync(
configuration,
districtName,
districtCode,
cancellationToken).ConfigureAwait(false);
}
var district = await ResolveElectionDistrictAsync(
configuration.SungerType,
configuration,
districtName,
districtCode,
cancellationToken).ConfigureAwait(false);
var path = BuildCountingPath(configuration, $"ids={Uri.EscapeDataString(district.Id)}");
var items = await GetArrayAsync<SbsCountingItem>(
$"gaepyo/{configuration.SungerType}/sungergus?ids={Uri.EscapeDataString(district.Id)}",
configuration.BaseUri,
path,
cancellationToken).ConfigureAwait(false);
var item = items.FirstOrDefault()
?? throw new InvalidOperationException("SBS API가 해당 지역의 개표 데이터를 반환하지 않았습니다.");
var result = CreateCountingRefreshResult(
configuration,
CreateDistrictSelectionOption(configuration.SungerType, district),
item,
$"GET /{path}");
if (result.Candidates is null || result.Candidates.Count == 0)
{
throw new InvalidOperationException("SBS API 응답에 후보자 정보가 없습니다.");
}
return result;
}
private async Task<SbsElectionRefreshResult> RefreshBasicCouncilCountingByDistrictIdAsync(
SbsElectionConfiguration configuration,
string districtName,
string districtCode,
CancellationToken cancellationToken)
{
var path = BuildCountingPath(configuration, $"ids={Uri.EscapeDataString(districtCode)}");
var items = await GetCountingItemsForPathAsync(
configuration,
path,
cancellationToken).ConfigureAwait(false);
var item = items.FirstOrDefault().Item
?? throw new InvalidOperationException("SBS API가 해당 지역의 개표 데이터를 반환하지 않았습니다.");
var region = CreateRegionInfo(item.Region) ?? new SbsRegionInfo
{
Id = districtCode,
Name = districtName,
Name1 = districtName,
Name4 = districtName,
Name1Id = ResolveBasicApiSidoCode(districtName)
};
var result = CreateCountingRefreshResult(
configuration,
CreateDistrictSelectionOption(configuration.SungerType, region),
item,
$"GET /{path}");
if (result.Candidates is null || result.Candidates.Count == 0)
{
throw new InvalidOperationException("SBS API 응답에 후보자 정보가 없습니다.");
}
return result;
}
private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsForDistrictsAsync(
SbsElectionConfiguration configuration,
IReadOnlyList<DistrictSelectionOption> districts,
CancellationToken cancellationToken)
{
if (CanQueryCountingBySido(configuration, districts))
{
return await GetCountingItemsBySidoAsync(configuration, districts, cancellationToken).ConfigureAwait(false);
}
var results = new List<SbsCountingResponseItem>();
foreach (var districtChunk in districts.Chunk(24))
{
var ids = string.Join(",", districtChunk.Select(district => Uri.EscapeDataString(district.DistrictCode)));
var path = BuildCountingPath(configuration, $"ids={ids}");
results.AddRange(await GetCountingItemsForPathAsync(
configuration,
path,
cancellationToken).ConfigureAwait(false));
}
return results;
}
private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsBySidoAsync(
SbsElectionConfiguration configuration,
IReadOnlyList<DistrictSelectionOption> districts,
CancellationToken cancellationToken)
{
var sidoCodes = districts
.Select(district => district.ParentRegionCode)
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(code => code, StringComparer.Ordinal)
.ToArray();
return await GetCountingItemsBySidoCodesAsync(
configuration,
sidoCodes,
cancellationToken).ConfigureAwait(false);
}
private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsBySidoCodesAsync(
SbsElectionConfiguration configuration,
IReadOnlyList<string> sidoCodes,
CancellationToken cancellationToken)
{
var results = new List<SbsCountingResponseItem>();
foreach (var sidoChunk in sidoCodes.Chunk(24))
{
var sidos = string.Join(",", sidoChunk.Select(Uri.EscapeDataString));
var path = BuildCountingPath(configuration, $"sidos={sidos}");
results.AddRange(await GetCountingItemsForPathAsync(
configuration,
path,
cancellationToken).ConfigureAwait(false));
}
return results;
}
private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsForPathAsync(
SbsElectionConfiguration configuration,
string path,
CancellationToken cancellationToken)
{
if (!ShouldCacheBasicCouncilCounting(configuration))
{
var uncachedItems = await GetArrayAsync<SbsCountingItem>(
configuration.BaseUri,
path,
cancellationToken).ConfigureAwait(false);
return uncachedItems.Select(item => new SbsCountingResponseItem(item, $"GET /{path}")).ToArray();
}
var cacheKey = $"{configuration.BaseUri.AbsoluteUri}|{path}";
await _countingCacheLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var now = DateTimeOffset.Now;
if (_countingCache.TryGetValue(cacheKey, out var cached) &&
now - cached.ReceivedAt < BasicCouncilCountingCacheDuration)
{
return cached.Items
.Select(item => new SbsCountingResponseItem(item, cached.SourcePath))
.ToArray();
}
var items = await GetArrayAsync<SbsCountingItem>(
configuration.BaseUri,
path,
cancellationToken).ConfigureAwait(false);
var responseItems = items.ToArray();
_countingCache[cacheKey] = new SbsCountingCacheEntry(
now,
responseItems,
$"GET /{path}");
return responseItems.Select(item => new SbsCountingResponseItem(item, $"GET /{path}")).ToArray();
}
finally
{
_countingCacheLock.Release();
}
}
private static SbsElectionRefreshResult CreateCountingRefreshResult(
SbsElectionConfiguration configuration,
DistrictSelectionOption district,
SbsCountingItem item,
string sourcePath)
{
var candidates = (item.Hubojas ?? [])
.Select(MapCandidate)
.OrderByDescending(candidate => candidate.VoteCount)
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
.ToArray();
if (candidates.Length == 0)
{
throw new InvalidOperationException("SBS API 응답에 후보자 정보가 없습니다.");
}
var totalVotes = item.Total?.Tupyosu ?? candidates.Sum(candidate => candidate.VoteCount);
var regionName = ExpandRegionName(item.Region?.Name1 ?? district.Name1 ?? districtName);
var fallbackRegion = CreateRegionInfo(district);
var regionName = ExpandRegionName(item.Region?.Name1 ?? fallbackRegion.Name1 ?? district.RegionName);
var outputRegionName = BuildOutputRegionName(regionName);
var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, district);
var displayName = configuration.SungerType == 4
var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, fallbackRegion);
var displayName = configuration.SungerType is 2 or 4 or 5 or 6
? BuildFullDistrictDisplayName(regionName, districtLabel)
: regionName;
return new SbsElectionRefreshResult(
DistrictName: displayName,
DistrictCode: district.Id,
DistrictCode: district.DistrictCode,
RegionName: outputRegionName,
ElectionDistrictName: districtLabel,
TotalExpectedVotes: Math.Max(totalVotes, 1),
@@ -345,7 +664,7 @@ public sealed class SbsElectionApiClient : IDisposable
RemainingVotes: item.Total?.UncountedPyosu,
Candidates: candidates,
ReceivedAt: DateTimeOffset.Now,
SourcePath: $"GET /gaepyo/{configuration.SungerType}/sungergus?ids={district.Id}");
SourcePath: sourcePath);
}
private static CandidateEntry MapCandidate(SbsCandidateItem item)
@@ -371,6 +690,7 @@ public sealed class SbsElectionApiClient : IDisposable
"40" => CandidateJudgement.Leading,
"50" => CandidateJudgement.Confirmed,
"60" => CandidateJudgement.ElectedInProgress,
"70" => CandidateJudgement.Elected,
"80" => CandidateJudgement.UnopposedElected,
"90" => CandidateJudgement.ElectedAfterCountComplete,
_ => CandidateJudgement.None
@@ -378,11 +698,15 @@ public sealed class SbsElectionApiClient : IDisposable
}
private async Task<SbsRegionInfo> ResolveSidoRegionAsync(
SbsElectionConfiguration configuration,
string districtName,
string districtCode,
CancellationToken cancellationToken)
{
_sidoRegions ??= await GetValueAsync<SbsRegionInfo>("sungerInfo/region?type=시도", cancellationToken).ConfigureAwait(false);
_sidoRegions ??= await GetValueAsync<SbsRegionInfo>(
configuration.BaseUri,
"sungerInfo/region?type=시도",
cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(districtCode))
{
@@ -403,12 +727,15 @@ public sealed class SbsElectionApiClient : IDisposable
}
private async Task<SbsRegionInfo> ResolveElectionDistrictAsync(
int sungerType,
SbsElectionConfiguration configuration,
string districtName,
string districtCode,
CancellationToken cancellationToken)
{
var regions = await GetElectionDistrictRegionsAsync(sungerType, cancellationToken).ConfigureAwait(false);
var regions = await GetElectionDistrictRegionsAsync(
configuration,
Array.Empty<string>(),
cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(districtCode))
{
@@ -462,39 +789,177 @@ public sealed class SbsElectionApiClient : IDisposable
}
private async Task<IReadOnlyList<SbsRegionInfo>> GetElectionDistrictRegionsAsync(
int sungerType,
SbsElectionConfiguration configuration,
IReadOnlyList<string> scopedSidoCodes,
CancellationToken cancellationToken)
{
if (!_districtRegions.TryGetValue(sungerType, out var regions))
var scopedKey = scopedSidoCodes.Count == 0
? "all"
: string.Join(",", scopedSidoCodes.OrderBy(code => code, StringComparer.Ordinal));
var cacheKey = $"{configuration.BaseUri.AbsoluteUri}|{configuration.SungerType}|{scopedKey}";
if (!_districtRegions.TryGetValue(cacheKey, out var regions))
{
regions = await GetValueAsync<SbsRegionInfo>(
$"sungerInfo/region?type=선거구&sungerType={sungerType}",
cancellationToken).ConfigureAwait(false);
_districtRegions[sungerType] = regions;
if (CanDeriveDistrictsFromCounting(configuration))
{
regions = scopedSidoCodes.Count == 0
? Array.Empty<SbsRegionInfo>()
: await GetCountingDistrictRegionsAsync(configuration, scopedSidoCodes, cancellationToken).ConfigureAwait(false);
}
else
{
regions = await GetValueAsync<SbsRegionInfo>(
configuration.BaseUri,
$"sungerInfo/region?type=선거구&sungerType={configuration.SungerType}",
cancellationToken)
.ConfigureAwait(false);
}
_districtRegions[cacheKey] = regions;
}
return regions;
}
private async Task<IReadOnlyList<T>> GetValueAsync<T>(string relativePath, CancellationToken cancellationToken)
private async Task<IReadOnlyList<SbsRegionInfo>> GetCountingDistrictRegionsAsync(
SbsElectionConfiguration configuration,
IReadOnlyList<string> scopedSidoCodes,
CancellationToken cancellationToken)
{
var json = await GetJsonAsync(relativePath, cancellationToken).ConfigureAwait(false);
var items = await GetCountingItemsBySidoCodesAsync(
configuration,
scopedSidoCodes,
cancellationToken).ConfigureAwait(false);
return items
.Select(item => CreateRegionInfo(item.Item.Region))
.Where(region => region is not null && !string.IsNullOrWhiteSpace(region.Id))
.Select(region => region!)
.GroupBy(region => region.Id, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(region => region.Order > 0 ? region.Order : int.MaxValue)
.ThenBy(region => region.Id, StringComparer.Ordinal)
.ToArray();
}
private async Task<IReadOnlyList<T>> GetValueAsync<T>(Uri baseUri, string relativePath, CancellationToken cancellationToken)
{
var json = await GetJsonAsync(baseUri, relativePath, cancellationToken).ConfigureAwait(false);
return DeserializeList<T>(json, relativePath, preferValueProperty: true);
}
private async Task<IReadOnlyList<T>> GetArrayAsync<T>(string relativePath, CancellationToken cancellationToken)
private async Task<IReadOnlyList<T>> GetArrayAsync<T>(Uri baseUri, string relativePath, CancellationToken cancellationToken)
{
var json = await GetJsonAsync(relativePath, cancellationToken).ConfigureAwait(false);
var json = await GetJsonAsync(baseUri, relativePath, cancellationToken).ConfigureAwait(false);
return DeserializeList<T>(json, relativePath, preferValueProperty: false);
}
private async Task<string> GetJsonAsync(string relativePath, CancellationToken cancellationToken)
private async Task<string> GetJsonAsync(Uri baseUri, string relativePath, CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(relativePath, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var requestUri = new Uri(baseUri, relativePath);
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
return Encoding.UTF8.GetString(bytes);
var body = Encoding.UTF8.GetString(bytes);
if (!response.IsSuccessStatusCode)
{
var detail = CreateResponseDetail(body);
throw new HttpRequestException(
$"SBS API 요청 실패: GET {requestUri.PathAndQuery} -> {(int)response.StatusCode} {response.ReasonPhrase}{detail}");
}
return body;
}
private static string BuildCountingPath(SbsElectionConfiguration configuration, string query)
=> string.IsNullOrWhiteSpace(query)
? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus"
: $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus?{query}";
private static bool CanDeriveDistrictsFromCounting(SbsElectionConfiguration configuration)
=> Uri.Compare(
configuration.BaseUri,
BasicApiBaseUri,
UriComponents.SchemeAndServer | UriComponents.Path,
UriFormat.SafeUnescaped,
StringComparison.OrdinalIgnoreCase) == 0 &&
configuration.SungerType == 6;
private static bool CanQueryCountingBySido(
SbsElectionConfiguration configuration,
IReadOnlyList<DistrictSelectionOption> districts)
=> configuration.SungerType == 6 &&
CanDeriveDistrictsFromCounting(configuration) &&
districts.Count > 0 &&
districts.All(district => !string.IsNullOrWhiteSpace(district.ParentRegionCode));
private static bool CanQueryBasicCouncilByDistrictId(SbsElectionConfiguration configuration)
=> configuration.SungerType == 6 && CanDeriveDistrictsFromCounting(configuration);
private static bool ShouldCacheBasicCouncilCounting(SbsElectionConfiguration configuration)
=> configuration.SungerType == 6 && CanDeriveDistrictsFromCounting(configuration);
public static IReadOnlyList<string> ResolveBasicApiSidoCodes(IEnumerable<string> regionNames)
{
return (regionNames ?? Array.Empty<string>())
.Select(ResolveBasicApiSidoCode)
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(code => code, StringComparer.Ordinal)
.ToArray();
}
public static string ResolveBasicApiSidoCode(string? regionName)
{
if (string.IsNullOrWhiteSpace(regionName))
{
return string.Empty;
}
var normalized = NormalizeRegionName(regionName);
return BasicApiSidoCodes.TryGetValue(normalized, out var code) ||
BasicApiSidoCodes.TryGetValue(regionName.Trim(), out code)
? code
: string.Empty;
}
private static Uri ResolveBasicApiBaseUri()
{
var configuredBaseUrl = Environment.GetEnvironmentVariable(BasicApiBaseUrlEnvironmentVariable);
if (!string.IsNullOrWhiteSpace(configuredBaseUrl) &&
Uri.TryCreate(EnsureTrailingSlash(configuredBaseUrl.Trim()), UriKind.Absolute, out var configuredUri))
{
return configuredUri;
}
return new Uri("http://210.180.17.148/sbs-basic-api/");
}
private static string ResolveBasicApiCountingEndpointSegment()
{
var mode = Environment.GetEnvironmentVariable(BasicApiModeEnvironmentVariable)?.Trim().ToLowerInvariant();
return mode switch
{
"dev" or "development" or "test" or "stage" or "staging" or "개발" => "gaepyo-dev",
"prod" or "production" or "real" or "operating" or "운영" => "gaepyo",
_ => "gaepyo"
};
}
private static string EnsureTrailingSlash(string value)
=> value.EndsWith("/", StringComparison.Ordinal) ? value : $"{value}/";
private static string CreateResponseDetail(string body)
{
var trimmed = body.Replace("\r", " ", StringComparison.Ordinal)
.Replace("\n", " ", StringComparison.Ordinal)
.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return string.Empty;
}
return $" / {trimmed[..Math.Min(trimmed.Length, 180)]}";
}
private static IReadOnlyList<T> DeserializeList<T>(string json, string relativePath, bool preferValueProperty)
@@ -549,7 +1014,7 @@ public sealed class SbsElectionApiClient : IDisposable
return sungerType switch
{
3 => BuildMayorGovernorLabel(regionName, region?.Name4 ?? fallback.Name4),
4 => BuildElectionDistrictLabel(region, fallback),
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region, fallback),
_ => regionName
};
}
@@ -644,10 +1109,10 @@ public sealed class SbsElectionApiClient : IDisposable
var districtName = sungerType switch
{
3 => BuildMayorGovernorLabel(regionName, region.Name4),
4 => BuildElectionDistrictLabel(region),
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region),
_ => regionName
};
var displayName = sungerType == 4
var displayName = sungerType is 2 or 4 or 5 or 6
? BuildFullDistrictDisplayName(regionName, districtName)
: regionName;
@@ -659,6 +1124,51 @@ public sealed class SbsElectionApiClient : IDisposable
ParentRegionCode: region.Name1Id ?? string.Empty);
}
private static SbsRegionInfo CreateRegionInfo(DistrictSelectionOption option)
{
return new SbsRegionInfo
{
Id = option.DistrictCode,
Name = option.DisplayName,
Name1 = option.RegionName,
Name2 = option.DistrictName,
Name4 = option.DistrictName,
Name1Id = option.ParentRegionCode
};
}
private static SbsRegionInfo? CreateRegionInfo(SbsTurnoutRegion? region)
{
if (region is null)
{
return null;
}
var id = !string.IsNullOrWhiteSpace(region.Id)
? region.Id
: region.Name4Id ?? string.Empty;
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
return new SbsRegionInfo
{
Id = id,
Name = region.Name ?? region.Name4 ?? string.Empty,
Name1 = region.Name1,
Name2 = region.Name2,
Name3 = region.Name3,
Name4 = region.Name4,
Name1Id = region.Name1Id,
Name2Id = region.Name2Id,
Name3Id = region.Name3Id,
Name4Id = region.Name4Id,
Order = region.Order
};
}
private static string NormalizeRegionName(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -707,7 +1217,11 @@ public sealed class SbsElectionApiClient : IDisposable
: value.Trim();
}
private readonly record struct SbsElectionConfiguration(int SungerType, bool SupportsPreElection);
private readonly record struct SbsElectionConfiguration(
int SungerType,
bool SupportsPreElection,
Uri BaseUri,
string CountingEndpointSegment);
public sealed record DistrictSelectionOption(
string DisplayName,
@@ -771,6 +1285,9 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("name2")]
public string? Name2 { get; set; }
[JsonPropertyName("name3")]
public string? Name3 { get; set; }
[JsonPropertyName("name4")]
public string? Name4 { get; set; }
@@ -779,6 +1296,15 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("name2Id")]
public string? Name2Id { get; set; }
[JsonPropertyName("name3Id")]
public string? Name3Id { get; set; }
[JsonPropertyName("name4Id")]
public string? Name4Id { get; set; }
[JsonPropertyName("order")]
public int Order { get; set; }
}
private sealed class SbsTurnoutItem
@@ -807,6 +1333,9 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("name2")]
public string? Name2 { get; set; }
[JsonPropertyName("name3")]
public string? Name3 { get; set; }
[JsonPropertyName("name4")]
public string? Name4 { get; set; }
@@ -816,8 +1345,14 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("name2Id")]
public string? Name2Id { get; set; }
[JsonPropertyName("name3Id")]
public string? Name3Id { get; set; }
[JsonPropertyName("name4Id")]
public string? Name4Id { get; set; }
[JsonPropertyName("order")]
public int Order { get; set; }
}
private sealed class SbsTurnoutVoteSnapshot
@@ -838,6 +1373,15 @@ public sealed class SbsElectionApiClient : IDisposable
public List<SbsCandidateItem>? Hubojas { get; set; }
}
private readonly record struct SbsCountingResponseItem(
SbsCountingItem Item,
string SourcePath);
private readonly record struct SbsCountingCacheEntry(
DateTimeOffset ReceivedAt,
IReadOnlyList<SbsCountingItem> Items,
string SourcePath);
private sealed class SbsCountingVoteSnapshot
{
[JsonPropertyName("tupyosu")]

View File

@@ -0,0 +1,86 @@
using System;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
internal static class ScheduleTemplatePolicy
{
public const string SingleRegionLabel = "단일";
public static double GetMinimumCutDurationSeconds(FormatTemplateDefinition template)
{
return GetMinimumCutDurationSeconds(template.RecommendedChannel, template.Name);
}
public static double GetMinimumCutDurationSeconds(BroadcastChannel channel, string? templateName)
{
var name = templateName ?? string.Empty;
if (name.Contains("영상", StringComparison.Ordinal) ||
name.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("사전_", StringComparison.Ordinal))
{
return 10d;
}
if (channel == BroadcastChannel.VideoWall)
{
return 8d;
}
if (channel is BroadcastChannel.Normal or BroadcastChannel.Bottom)
{
return 8d;
}
return 6d;
}
public static double NormalizeCutDurationSeconds(double durationSeconds, FormatTemplateDefinition template)
{
return NormalizeCutDurationSeconds(durationSeconds, template.RecommendedChannel, template.Name);
}
public static double NormalizeCutDurationSeconds(
double durationSeconds,
BroadcastChannel channel,
string? templateName)
{
if (double.IsNaN(durationSeconds) || double.IsInfinity(durationSeconds))
{
return GetMinimumCutDurationSeconds(channel, templateName);
}
var minimum = GetMinimumCutDurationSeconds(channel, templateName);
return Math.Max(minimum, Math.Round(durationSeconds, 1, MidpointRounding.AwayFromZero));
}
public static bool UsesSingleRegionOption(FormatTemplateDefinition? template)
{
return template is not null && IsStaticHistoricalTrendFormat(template.Name);
}
public static bool IsStaticHistoricalTrendFormat(string? templateName)
{
return string.Equals(templateName, "역대시도판세_광역단체장", StringComparison.Ordinal) ||
string.Equals(templateName, "역대시도판세_기초단체장", StringComparison.Ordinal);
}
public static bool IsHistoricalTurnoutFormat(string? templateName)
{
return !string.IsNullOrWhiteSpace(templateName) &&
templateName.StartsWith("사전_역대투표율", StringComparison.Ordinal);
}
public static bool IsHistoricalWinnerFormat(string? templateName)
{
return !string.IsNullOrWhiteSpace(templateName) &&
templateName.StartsWith("사전_역대당선", StringComparison.Ordinal);
}
public static bool IsCouncilSeatTableFormat(string? templateName)
{
return !string.IsNullOrWhiteSpace(templateName) &&
(templateName.Contains("광역의원표", StringComparison.Ordinal) ||
templateName.Contains("기초의원표", StringComparison.Ordinal));
}
}

View File

@@ -21,6 +21,7 @@ public sealed class TornadoManager : IDisposable
private readonly Timer _reconnectTimer;
private readonly Dictionary<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, DateTime> _sceneWriteTimes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<int, KAScenePlayer> _scenePlayers = new();
private IKAEngine? _engine;
@@ -65,13 +66,16 @@ public sealed class TornadoManager : IDisposable
throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
}
var sceneWriteTime = ResolveSceneWriteTime(scenePath);
var existingAlias = await _dispatcher.InvokeAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
if (_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase))
string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase) &&
_sceneWriteTimes.TryGetValue(sceneAlias, out var existingWriteTime) &&
existingWriteTime == sceneWriteTime)
{
return sceneAlias;
}
@@ -104,6 +108,7 @@ public sealed class TornadoManager : IDisposable
_scenes[sceneAlias] = scene ?? throw new InvalidOperationException($"Failed to load Karisma scene: {scenePath}");
_scenePaths[sceneAlias] = scenePath;
_sceneWriteTimes[sceneAlias] = sceneWriteTime;
}, cancellationToken).ConfigureAwait(false);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -126,6 +131,7 @@ public sealed class TornadoManager : IDisposable
{
_scenes.Remove(sceneAlias);
_scenePaths.Remove(sceneAlias);
_sceneWriteTimes.Remove(sceneAlias);
}, CancellationToken.None).ConfigureAwait(false);
}
catch
@@ -141,6 +147,8 @@ public sealed class TornadoManager : IDisposable
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesBeforeValue,
IReadOnlyDictionary<string, string> values,
IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
IReadOnlyList<KarismaChartCellUpdate> chartCellUpdates,
IReadOnlyList<KarismaPositionUpdate> positionUpdates,
IReadOnlyList<KarismaStyleColorUpdate> styleColorUpdates,
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesAfterValue,
CancellationToken cancellationToken)
@@ -205,6 +213,60 @@ public sealed class TornadoManager : IDisposable
}
}
foreach (var chartCellUpdate in chartCellUpdates)
{
if (string.IsNullOrWhiteSpace(chartCellUpdate.ObjectName))
{
continue;
}
try
{
var sceneObject = scene.GetObject(chartCellUpdate.ObjectName);
if (sceneObject is not IKAChart chart)
{
_logService.Warning(
$"Karisma chart cell update skipped: scene={sceneAlias} object={chartCellUpdate.ObjectName} reason=object is not a chart");
continue;
}
chart.SetChartCellData(chartCellUpdate.Row, chartCellUpdate.Column, chartCellUpdate.Value);
}
catch (Exception ex)
{
_logService.Warning(
$"Karisma chart cell update skipped: scene={sceneAlias} object={chartCellUpdate.ObjectName} row={chartCellUpdate.Row} column={chartCellUpdate.Column} reason={ex.Message}");
}
}
foreach (var positionUpdate in positionUpdates)
{
if (string.IsNullOrWhiteSpace(positionUpdate.ObjectName))
{
continue;
}
try
{
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
if (sceneObject is null)
{
continue;
}
sceneObject.SetPosition(
positionUpdate.X,
positionUpdate.Y,
positionUpdate.Z,
positionUpdate.VectorType);
}
catch (Exception ex)
{
_logService.Warning(
$"Karisma position update skipped: scene={sceneAlias} object={positionUpdate.ObjectName} reason={ex.Message}");
}
}
foreach (var styleColorUpdate in styleColorUpdates)
{
if (string.IsNullOrWhiteSpace(styleColorUpdate.ObjectName))
@@ -246,6 +308,18 @@ public sealed class TornadoManager : IDisposable
}, cancellationToken);
}
private static DateTime ResolveSceneWriteTime(string scenePath)
{
try
{
return File.GetLastWriteTimeUtc(scenePath);
}
catch
{
return DateTime.MinValue;
}
}
private void ApplyVisibilityUpdates(string sceneAlias, KAScene scene, IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdates)
{
foreach (var visibilityUpdate in visibilityUpdates)

View File

@@ -1,109 +1,16 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Tornado3_2026Election.Services;
public static class TornadoPathResolver
{
public const string FixedT3CutPath = @"D:\Elect2026\T3_Cut";
public static string GetDefaultT3CutPath()
{
foreach (var candidate in GetCandidatePaths())
{
var normalized = NormalizeConfiguredPath(candidate);
if (!string.IsNullOrWhiteSpace(normalized) && Directory.Exists(normalized))
{
return normalized;
}
}
return NormalizeConfiguredPath(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"Tornado3 Data",
"T3_Cut",
"T3_Cut"));
return FixedT3CutPath;
}
public static string NormalizeConfiguredPath(string? configuredPath)
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
return string.Empty;
}
var trimmedPath = configuredPath.Trim();
if (!Directory.Exists(trimmedPath))
{
return trimmedPath;
}
var nestedDefault = Path.Combine(trimmedPath, "T3_Cut");
if (ContainsSceneFiles(nestedDefault))
{
return nestedDefault;
}
if (ContainsSceneFiles(trimmedPath))
{
return trimmedPath;
}
var nestedDirectory = TryFindNestedSceneDirectory(trimmedPath);
return nestedDirectory ?? trimmedPath;
}
private static IEnumerable<string> GetCandidatePaths()
{
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var configured = Environment.GetEnvironmentVariable("TORNADO_T3CUT_PATH");
if (!string.IsNullOrWhiteSpace(configured))
{
yield return configured;
}
if (!string.IsNullOrWhiteSpace(documents))
{
yield return Path.Combine(documents, "Tornado3 Data", "T3_Cut", "T3_Cut");
yield return Path.Combine(documents, "Tornado3 Data", "T3_Cut");
}
if (!string.IsNullOrWhiteSpace(userProfile))
{
yield return Path.Combine(userProfile, "Downloads", "T3_Cut");
}
}
private static string? TryFindNestedSceneDirectory(string rootPath)
{
try
{
return Directory.EnumerateDirectories(rootPath, "*", SearchOption.TopDirectoryOnly)
.Select(NormalizeConfiguredPath)
.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && ContainsSceneFiles(path));
}
catch
{
return null;
}
}
private static bool ContainsSceneFiles(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
{
return false;
}
try
{
return Directory.EnumerateFiles(path, "*.tscn", SearchOption.AllDirectories).Any();
}
catch
{
return false;
}
return FixedT3CutPath;
}
}

View File

@@ -33,6 +33,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
private int _regionOptionsRevision;
private string _lastRegionOptionFormatId = string.Empty;
private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
private double _selectedFormatThumbnailWidth = 320;
private double _selectedFormatThumbnailHeight = 180;
@@ -506,10 +507,12 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
RegionOptions.Clear();
SelectedRegionOption = null;
_lastRegionOptionFormatId = string.Empty;
AddFormatCommand.NotifyCanExecuteChanged();
return;
}
var previousRegionOptionFormatId = _lastRegionOptionFormatId;
var options = await _data.GetScheduleRegionOptionsAsync(selectedFormat);
if (revision != _regionOptionsRevision)
{
@@ -522,7 +525,12 @@ public sealed class ChannelScheduleViewModel : ObservableObject
RegionOptions.Add(option);
}
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection);
var shouldUseDefaultSelection = !string.Equals(
previousRegionOptionFormatId,
selectedFormat.Id,
StringComparison.Ordinal);
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
_lastRegionOptionFormatId = selectedFormat.Id;
AddFormatCommand.NotifyCanExecuteChanged();
}
@@ -554,16 +562,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private static ScheduleRegionOption? ResolvePreferredRegionOption(
IReadOnlyList<ScheduleRegionOption> options,
ScheduleRegionOption? previousSelection)
ScheduleRegionOption? previousSelection,
FormatTemplateDefinition selectedFormat,
bool shouldUseDefaultSelection)
{
if (options.Count == 0)
{
return null;
}
if (previousSelection is null)
if (previousSelection is null || shouldUseDefaultSelection)
{
return options[0];
return ResolveDefaultRegionOption(options, selectedFormat);
}
if (previousSelection.Scope == ScheduleRegionScope.Single)
@@ -577,7 +587,26 @@ public sealed class ChannelScheduleViewModel : ObservableObject
}
}
return options.FirstOrDefault(option => option.Scope == previousSelection.Scope) ?? options[0];
return options.FirstOrDefault(option => option.Scope == previousSelection.Scope) ??
ResolveDefaultRegionOption(options, selectedFormat);
}
private static ScheduleRegionOption ResolveDefaultRegionOption(
IReadOnlyList<ScheduleRegionOption> options,
FormatTemplateDefinition selectedFormat)
{
var defaultScope = UsesAllDefaultRegionScope(selectedFormat)
? ScheduleRegionScope.All
: ScheduleRegionScope.StationRegions;
return options.FirstOrDefault(option => option.Scope == defaultScope) ?? options[0];
}
private static bool UsesAllDefaultRegionScope(FormatTemplateDefinition format)
{
var source = $"{format.Name} {format.Id}";
return source.Contains("광역단체장", StringComparison.Ordinal) ||
source.Contains("교육감", StringComparison.Ordinal);
}
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)

View File

@@ -28,7 +28,8 @@ public sealed class CutListEntryViewModel : ObservableObject
_cut = cut;
_durationChanged = durationChanged;
_videoWallLayoutPreset = videoWallLayoutPreset;
_durationSeconds = cut.DurationSeconds;
_durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
_cut.DurationSeconds = _durationSeconds;
_thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(template);
ApplyThumbnailLayout();
}
@@ -56,6 +57,8 @@ public sealed class CutListEntryViewModel : ObservableObject
public string ElectionCategoryLabel => CutListElectionCategoryResolver.GetLabel(ElectionCategory);
public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(_template);
public ImageSource? ThumbnailSource => _thumbnailSource;
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(_template);
@@ -74,7 +77,7 @@ public sealed class CutListEntryViewModel : ObservableObject
return;
}
var normalized = Math.Max(1, Math.Round(value, 1, MidpointRounding.AwayFromZero));
var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(value, _template);
if (!SetProperty(ref _durationSeconds, normalized))
{
return;
@@ -92,7 +95,8 @@ public sealed class CutListEntryViewModel : ObservableObject
public void RefreshFromSource()
{
var sourceValue = _cut.DurationSeconds;
var sourceValue = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(_cut.DurationSeconds, _template);
_cut.DurationSeconds = sourceValue;
if (Math.Abs(_durationSeconds - sourceValue) >= 0.001d)
{
SetProperty(ref _durationSeconds, sourceValue);

View File

@@ -19,6 +19,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private const string DistrictOverviewOptionLabel = "전체보기";
private const string StationRegionOverviewOptionValue = "__SELECTED_REGIONS__";
private const string StationRegionOverviewOptionLabel = "선택권역보기";
private const string CouncilSeatCandidateCodePrefix = "SEAT:";
private const string CouncilSeatDistrictCandidateCodePrefix = "SEAT:D:";
private const string CouncilSeatProportionalCandidateCodePrefix = "SEAT:P:";
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
@@ -184,6 +187,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
ElectionTypeOptions =
[
new SelectionOption<string>("국회의원", "국회의원"),
new SelectionOption<string>("광역단체장", "광역단체장"),
new SelectionOption<string>("교육감", "교육감"),
new SelectionOption<string>("광역의원", "광역의원"),
@@ -292,16 +296,16 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
public bool IsCountingPhase => BroadcastPhase == BroadcastPhase.Counting;
public string BroadcastPhaseLabel => IsPreElectionPhase ? "사전" : "개표";
public string BroadcastPhaseLabel => IsPreElectionPhase ? "투표" : "개표";
public string BroadcastPhaseBadgeText => IsPreElectionPhase ? "사전 투표율 수신" : "개표 득표수 수신";
public string BroadcastPhaseBadgeText => IsPreElectionPhase ? "투표율 수신" : "개표 득표수 수신";
public string BroadcastPhaseDetailText => IsPreElectionPhase
? "사전 단계에서는 투표율과 투표자 수를 수신합니다."
? "투표 단계에서는 투표율과 투표자 수를 수신합니다."
: "개표 단계에서는 후보 득표수와 판정 데이터를 수신합니다.";
public string HeaderMetricSummary => IsPreElectionPhase
? $"사전 투표율 {TurnoutRateDisplay} / 투표자 {TurnoutVotes:N0}"
? $"투표율 {TurnoutRateDisplay} / 투표자 {TurnoutVotes:N0}"
: $"개표율 {CountedRateDisplay} / 개표 {CountedVotes:N0} / 남은표 {RemainingVotes:N0}";
public string SituationMetricPrimaryLabel => IsPreElectionPhase ? "투표율" : "개표수";
@@ -355,8 +359,8 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
? "선택 권역 개표율"
: "전체 개표율"
: IsStationRegionOverviewMode
? "선택 권역 보기"
: "전체 보기";
? "선택 권역 투표율"
: "전체 투표율";
public string DistrictOverviewStatusText
{
@@ -879,7 +883,14 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
_configuredRegions = normalizedRegions;
OnPropertyChanged(nameof(HasConfiguredRegionFilter), nameof(ConfiguredRegionFilterHintText));
ReapplyDistrictSelectionOptions();
if (IsBasicCouncilElectionType(ElectionType))
{
_ = RefreshDistrictOptionsForElectionTypeAsync();
}
else
{
ReapplyDistrictSelectionOptions();
}
}
public void SetSelectedStationContext(string stationId, string stationName)
@@ -929,6 +940,19 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
CancellationToken cancellationToken = default)
{
var electionType = ResolveScheduleElectionType(template);
if (ScheduleTemplatePolicy.UsesSingleRegionOption(template))
{
return
[
new ScheduleRegionOption
{
Scope = ScheduleRegionScope.Single,
Label = ScheduleTemplatePolicy.SingleRegionLabel,
ElectionType = electionType
}
];
}
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
var regionOptions = new List<ScheduleRegionOption>
@@ -967,6 +991,30 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
public bool ValidateSnapshotForFormat(FormatTemplateDefinition template, ElectionDataSnapshot snapshot, out string errorMessage)
{
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
{
if (snapshot.HistoricalTurnoutHistory.Count == 0)
{
errorMessage = "역대 투표율 데이터가 없습니다.";
return false;
}
errorMessage = string.Empty;
return true;
}
if (ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name))
{
if (snapshot.HistoricalWinnerHistory.Count == 0)
{
errorMessage = "역대 당선자 데이터가 없습니다.";
return false;
}
errorMessage = string.Empty;
return true;
}
if (IsTurnoutTemplate(template) &&
(snapshot.TurnoutVotes <= 0 || snapshot.TurnoutRate <= 0))
{
@@ -1098,6 +1146,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
CancellationToken cancellationToken)
{
var electionType = ResolveScheduleElectionType(template, item.ScheduleElectionType);
if (ScheduleTemplatePolicy.UsesSingleRegionOption(template))
{
return [CreateSingleScheduleRegionTarget(electionType)];
}
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
if (options.Count == 0)
{
@@ -1121,6 +1174,21 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
var electionType = string.IsNullOrWhiteSpace(target.ElectionType)
? ResolveScheduleElectionType(item.FormatName, item.ScheduleElectionType)
: target.ElectionType;
if (ScheduleTemplatePolicy.UsesSingleRegionOption(template))
{
return CreateSingleScheduleSnapshot(electionType, target);
}
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
{
return CreateHistoricalTurnoutScheduleSnapshot(electionType, target);
}
if (ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name))
{
return CreateHistoricalWinnerScheduleSnapshot(electionType, target);
}
if (ShouldUseTurnoutOverviewSnapshot(template, electionType))
{
return await CreateTurnoutScheduleSnapshotAsync(
@@ -1148,6 +1216,15 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
CancellationToken cancellationToken)
{
var electionType = ResolveScheduleElectionType(template, item.ScheduleElectionType);
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return CreateCouncilSeatScheduleSnapshotAsync(
electionType,
station,
regionTargets,
cancellationToken);
}
if (IsBottomTurnoutBoardTemplate(template) && ShouldUseTurnoutOverviewSnapshot(template, electionType))
{
return CreateTurnoutScheduleSnapshotAsync(
@@ -1182,7 +1259,10 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
if (SupportsApiDistrictOptions(ElectionType))
{
_logService.Info($"{ElectionType} 선거구 목록을 SBS API 기준으로 불러옵니다.");
var options = await _apiClient.GetDistrictOptionsAsync(ElectionType, CancellationToken.None);
var options = await _apiClient.GetDistrictOptionsAsync(
ElectionType,
ResolveApiDistrictRegionScope(ElectionType),
CancellationToken.None);
if (revision != _districtOptionsRevision)
{
return;
@@ -1196,6 +1276,17 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
QueueSelectionRefresh("선거 종류 변경");
return;
}
if (IsBasicCouncilElectionType(ElectionType))
{
ApplyDistrictSelectionSource(
Array.Empty<SbsElectionApiClient.DistrictSelectionOption>(),
preferredName,
preferredCode,
preferredRegionName);
_logService.Warning($"{ElectionType} 선거구 목록을 불러오지 못했습니다. 선택권역 설정을 확인하세요.");
return;
}
}
var fallbackPreferences = CaptureCurrentDistrictPreferences();
@@ -1214,6 +1305,17 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
}
var fallbackPreferences = CaptureCurrentDistrictPreferences();
if (IsBasicCouncilElectionType(ElectionType))
{
ApplyDistrictSelectionSource(
Array.Empty<SbsElectionApiClient.DistrictSelectionOption>(),
fallbackPreferences.PreferredName,
fallbackPreferences.PreferredCode,
fallbackPreferences.PreferredRegionName);
_logService.Warning($"기초의원 선거구 목록 갱신 실패: {ex.Message}");
return;
}
ApplyDistrictSelectionSource(
DefaultDistrictOptions,
fallbackPreferences.PreferredName,
@@ -1910,9 +2012,10 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private (IReadOnlyList<PreElectionHistoricalTurnoutEntry> TurnoutHistory, IReadOnlyList<PreElectionHistoricalWinnerEntry> WinnerHistory) ResolvePreElectionHistoryRecords(
string electionType,
string regionName,
string districtName)
string districtName,
bool includeOutsidePreElection = false)
{
if (BroadcastPhase != BroadcastPhase.PreElection)
if (!includeOutsidePreElection && BroadcastPhase != BroadcastPhase.PreElection)
{
return (Array.Empty<PreElectionHistoricalTurnoutEntry>(), Array.Empty<PreElectionHistoricalWinnerEntry>());
}
@@ -2025,7 +2128,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
}
return sourceOptions
.Where(option => _configuredRegions.Contains(NormalizeConfiguredRegion(option.RegionName)))
.Where(option => MatchesConfiguredRegion(option.RegionName, _configuredRegions))
.ToArray();
}
@@ -2244,13 +2347,6 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return;
}
if (!IsCountingPhase)
{
ReplaceDistrictOverviewCards([]);
DistrictOverviewStatusText = $"{GetDistrictOverviewModeLabel()}는 개표 단계에서 지역별 개표율 카드로 표시됩니다.";
return;
}
var visibleOptions = GetDistrictOverviewSelectionOptions(_districtSelectionSource);
if (visibleOptions.Count == 0)
{
@@ -2261,10 +2357,35 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
var revision = Interlocked.Increment(ref _districtOverviewRefreshRevision);
_isRefreshingDistrictOverview = true;
DistrictOverviewStatusText = $"{reason}으로 지역별 개표율을 불러오는 중입니다.";
DistrictOverviewStatusText = IsPreElectionPhase
? $"{reason}으로 지역별 투표율을 불러오는 중입니다."
: $"{reason}으로 지역별 개표율을 불러오는 중입니다.";
try
{
if (IsPreElectionPhase)
{
var turnoutOverview = await _apiClient.GetTurnoutOverviewAsync(ElectionType, visibleOptions, CancellationToken.None);
if (revision != _districtOverviewRefreshRevision || !IsDistrictOverviewMode)
{
return;
}
ReplaceDistrictOverviewCards(
turnoutOverview.Items.Select(item => new DistrictOverviewCardViewModel
{
DistrictViewName = item.DisplayName,
RegionName = item.DisplayName,
CountedRateDisplay = $"{item.TurnoutRate:0.0}%",
DetailText = $"투표 {item.TurnoutVotes:N0} / 미투표 {Math.Max(0, item.TotalExpectedVotes - item.TurnoutVotes):N0}"
}));
DistrictOverviewStatusText = DistrictOverviewCards.Count == 0
? "수신된 지역별 투표율이 없습니다."
: $"총 {DistrictOverviewCards.Count}개 지역 / 전국 {turnoutOverview.NationalTurnoutRate:0.0}% / 마지막 갱신 {DateTimeOffset.Now:HH:mm:ss}";
return;
}
var overviewItems = await _apiClient.GetCountingOverviewAsync(ElectionType, visibleOptions, CancellationToken.None);
if (revision != _districtOverviewRefreshRevision || !IsDistrictOverviewMode)
{
@@ -2334,7 +2455,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
{
if (!IsDistrictOverviewMode)
{
DistrictOverviewStatusText = "전체보기나 선택권역보기를 선택하면 지역별 개표율을 확인할 수 있습니다.";
DistrictOverviewStatusText = $"전체보기나 선택권역보기를 선택하면 지역별 {GetDistrictOverviewMetricLabel()}을 확인할 수 있습니다.";
return;
}
@@ -2344,12 +2465,6 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return;
}
if (!IsCountingPhase)
{
DistrictOverviewStatusText = $"{GetDistrictOverviewModeLabel()}는 개표 단계에서 지역별 개표율 카드로 표시됩니다.";
return;
}
if (_isRefreshingDistrictOverview)
{
return;
@@ -2366,11 +2481,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
if (IsStationRegionOverviewMode)
{
return _configuredRegions.Count == 0
? "설정에서 권역을 선택하면 선택권역보기로 해당 지역 개표율을 볼 수 있습니다."
? $"설정에서 권역을 선택하면 선택권역보기로 해당 지역 {GetDistrictOverviewMetricLabel()}을 볼 수 있습니다."
: "선택한 권역에 표시할 선거구가 없습니다.";
}
return "전체보기에서 지역별 개표율을 불러올 수 있습니다.";
return $"전체보기에서 지역별 {GetDistrictOverviewMetricLabel()}을 불러올 수 있습니다.";
}
private string GetDistrictOverviewModeLabel()
@@ -2378,6 +2493,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return IsStationRegionOverviewMode ? StationRegionOverviewOptionLabel : DistrictOverviewOptionLabel;
}
private string GetDistrictOverviewMetricLabel()
{
return IsPreElectionPhase ? "투표율" : "개표율";
}
private static IEnumerable<SelectionOption<string>> CreateDistrictViewSelectionOptions(
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> options)
{
@@ -2401,11 +2521,18 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
if (SupportsApiDistrictOptions(electionType))
{
var options = await _apiClient.GetDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
var options = await _apiClient
.GetDistrictOptionsAsync(electionType, ResolveApiDistrictRegionScope(electionType), cancellationToken)
.ConfigureAwait(false);
if (options.Count > 0)
{
return options;
}
if (IsBasicCouncilElectionType(electionType))
{
return Array.Empty<SbsElectionApiClient.DistrictSelectionOption>();
}
}
return _districtSelectionSource.Count > 0 ? _districtSelectionSource : DefaultDistrictOptions;
@@ -2424,15 +2551,27 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return "교육감";
}
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal) ||
resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
if (resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
{
return "기초의원";
}
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal))
{
return "기초단체장";
}
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal) ||
resolvedFormatName.Contains("광역의원", StringComparison.Ordinal) ||
resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
if (resolvedFormatName.Contains("광역의원", StringComparison.Ordinal))
{
return "광역의원";
}
if (resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
{
return "국회의원";
}
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal))
{
return "광역단체장";
}
@@ -2459,7 +2598,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
}
return options
.Where(option => configuredRegions.Contains(NormalizeConfiguredRegion(option.RegionName)))
.Where(option => MatchesConfiguredRegion(option.RegionName, configuredRegions))
.Select(option => CreateScheduleRegionTarget(option, electionType))
.ToArray();
}
@@ -2523,6 +2662,165 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
};
}
private async Task<ElectionDataSnapshot> CreateCouncilSeatScheduleSnapshotAsync(
string electionType,
BroadcastStationProfile station,
IReadOnlyList<ScheduleRegionTarget> regionTargets,
CancellationToken cancellationToken)
{
if (!SupportsApiDistrictOptions(electionType))
{
throw new InvalidOperationException($"{electionType} 의석 집계는 현재 SBS API 연동 대상이 아닙니다.");
}
var selectedTargets = regionTargets
.Where(target => !string.IsNullOrWhiteSpace(target.DistrictCode))
.GroupBy(target => target.DistrictCode, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToArray();
if (selectedTargets.Length == 0)
{
throw new InvalidOperationException("의석 집계 대상 선거구가 없습니다.");
}
var districtOptions = selectedTargets
.Select(target => new SbsElectionApiClient.DistrictSelectionOption(
target.DisplayName,
target.DistrictCode,
target.RegionName,
target.DistrictName,
ResolveScheduleTargetParentRegionCode(target)))
.ToArray();
var refreshResults = await _apiClient
.GetCountingSnapshotsAsync(electionType, districtOptions, cancellationToken)
.ConfigureAwait(false);
if (refreshResults.Count == 0)
{
throw new InvalidOperationException("의석 집계용 개표 데이터가 없습니다.");
}
var allCandidates = refreshResults
.SelectMany(result => result.Candidates ?? Array.Empty<CandidateEntry>())
.ToArray();
var seatCandidates = BuildCouncilSeatSummaryCandidates(allCandidates);
if (seatCandidates.Length == 0)
{
throw new InvalidOperationException("의석으로 집계할 유력/확정/당선 후보가 없습니다.");
}
var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes));
var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes));
var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0));
var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 0));
var countedRate = totalVotes <= 0
? refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max()
: Math.Round(countedVotes * 100d / totalVotes, 1, MidpointRounding.AwayFromZero);
var regionName = ResolveCouncilSeatAggregateRegionLabel(station, selectedTargets);
var districtName = selectedTargets.Length == 1
? selectedTargets[0].DisplayName
: regionName;
var history = ResolvePreElectionHistoryRecords(electionType, regionName, districtName);
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase.Counting,
ElectionType = electionType,
DistrictName = districtName,
DistrictCode = selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
RegionName = regionName,
ElectionDistrictName = selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName,
Candidates = seatCandidates,
TotalExpectedVotes = totalVotes,
TurnoutVotes = turnoutVotes,
CountedVotesFromApi = countedVotes,
RemainingVotesFromApi = remainingVotes,
CountedRateFromApi = countedRate,
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(),
HistoricalTurnoutHistory = history.TurnoutHistory,
HistoricalWinnerHistory = history.WinnerHistory
};
}
private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(IReadOnlyList<CandidateEntry> candidates)
{
return candidates
.Where(candidate => CountsAsCouncilSeat(candidate.EffectiveJudgement))
.GroupBy(candidate => ResolveCouncilSeatParty(candidate), StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var first = group.First();
return new
{
Party = group.Key,
ColorParty = ResolveCouncilSeatColorParty(first),
SeatCount = group.Count()
};
})
.Where(row => row.SeatCount > 0)
.OrderByDescending(row => row.SeatCount)
.ThenBy(row => row.Party, StringComparer.Ordinal)
.Select((row, index) => new CandidateEntry
{
CandidateCode = $"{CouncilSeatDistrictCandidateCodePrefix}{index + 1:00}",
BallotNumber = (index + 1).ToString(),
Name = row.Party,
Party = row.Party,
ColorParty = row.ColorParty,
VoteCount = row.SeatCount,
VoteRate = row.SeatCount,
HasImage = false,
ManualJudgement = CandidateJudgement.None,
AutomaticJudgement = CandidateJudgement.Elected
})
.ToArray();
}
private static bool CountsAsCouncilSeat(CandidateJudgement judgement)
{
return judgement is CandidateJudgement.Leading or
CandidateJudgement.Confirmed or
CandidateJudgement.Elected or
CandidateJudgement.ElectedInProgress or
CandidateJudgement.UnopposedElected or
CandidateJudgement.ElectedAfterCountComplete;
}
private static string ResolveCouncilSeatParty(CandidateEntry candidate)
{
if (!string.IsNullOrWhiteSpace(candidate.Party))
{
return candidate.Party.Trim();
}
return string.IsNullOrWhiteSpace(candidate.EffectiveColorParty)
? "무소속"
: candidate.EffectiveColorParty.Trim();
}
private static string ResolveCouncilSeatColorParty(CandidateEntry candidate)
{
return string.IsNullOrWhiteSpace(candidate.EffectiveColorParty)
? ResolveCouncilSeatParty(candidate)
: candidate.EffectiveColorParty.Trim();
}
private static string ResolveCouncilSeatAggregateRegionLabel(
BroadcastStationProfile station,
IReadOnlyList<ScheduleRegionTarget> selectedTargets)
{
var regionNames = selectedTargets
.Select(target => target.RegionName)
.Where(regionName => !string.IsNullOrWhiteSpace(regionName))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (regionNames.Length == 1)
{
return regionNames[0];
}
return string.IsNullOrWhiteSpace(station.Name) ? "전체" : station.Name;
}
private async Task<ElectionDataSnapshot> CreateTurnoutScheduleSnapshotAsync(
string electionType,
IReadOnlyList<ScheduleRegionTarget> regionTargets,
@@ -2622,9 +2920,29 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private static bool ShouldUseTurnoutOverviewSnapshot(FormatTemplateDefinition template, string electionType)
{
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) &&
!ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) &&
template.Name.Contains("투표율", StringComparison.Ordinal);
}
private IEnumerable<string> ResolveApiDistrictRegionScope(string electionType)
{
return IsBasicCouncilElectionType(electionType)
? _configuredRegions
: Array.Empty<string>();
}
private string ResolveScheduleTargetParentRegionCode(ScheduleRegionTarget target)
{
var matchedOption = _districtSelectionSource.FirstOrDefault(option =>
string.Equals(option.DistrictCode, target.DistrictCode, StringComparison.OrdinalIgnoreCase));
if (matchedOption is not null && !string.IsNullOrWhiteSpace(matchedOption.ParentRegionCode))
{
return matchedOption.ParentRegionCode;
}
return SbsElectionApiClient.ResolveBasicApiSidoCode(target.RegionName);
}
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Bottom &&
@@ -2675,6 +2993,114 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
option.DistrictCode);
}
private static ScheduleRegionTarget CreateSingleScheduleRegionTarget(string electionType)
{
return new ScheduleRegionTarget(
electionType,
ScheduleTemplatePolicy.SingleRegionLabel,
string.Empty,
ScheduleTemplatePolicy.SingleRegionLabel,
string.Empty);
}
private ElectionDataSnapshot CreateSingleScheduleSnapshot(
string electionType,
ScheduleRegionTarget target)
{
var displayName = string.IsNullOrWhiteSpace(target.DisplayName)
? ScheduleTemplatePolicy.SingleRegionLabel
: target.DisplayName;
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase,
ElectionType = electionType,
DistrictName = displayName,
DistrictCode = string.Empty,
RegionName = string.Empty,
ElectionDistrictName = displayName,
Candidates = Array.Empty<CandidateEntry>(),
TotalExpectedVotes = 0,
TurnoutVotes = 0,
CountedVotesFromApi = null,
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = DateTimeOffset.Now
};
}
private ElectionDataSnapshot CreateHistoricalTurnoutScheduleSnapshot(
string electionType,
ScheduleRegionTarget target)
{
var regionName = target.RegionName ?? string.Empty;
var districtName = !string.IsNullOrWhiteSpace(target.DistrictName)
? target.DistrictName
: !string.IsNullOrWhiteSpace(target.DisplayName)
? target.DisplayName
: regionName;
var history = ResolvePreElectionHistoryRecords(
electionType,
regionName,
districtName,
includeOutsidePreElection: true);
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase,
ElectionType = electionType,
DistrictName = districtName,
DistrictCode = target.DistrictCode,
RegionName = regionName,
ElectionDistrictName = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
Candidates = Array.Empty<CandidateEntry>(),
TotalExpectedVotes = 0,
TurnoutVotes = 0,
CountedVotesFromApi = null,
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = DateTimeOffset.Now,
HistoricalTurnoutHistory = history.TurnoutHistory,
HistoricalWinnerHistory = history.WinnerHistory
};
}
private ElectionDataSnapshot CreateHistoricalWinnerScheduleSnapshot(
string electionType,
ScheduleRegionTarget target)
{
var regionName = target.RegionName ?? string.Empty;
var districtName = !string.IsNullOrWhiteSpace(target.DistrictName)
? target.DistrictName
: !string.IsNullOrWhiteSpace(target.DisplayName)
? target.DisplayName
: regionName;
var history = ResolvePreElectionHistoryRecords(
electionType,
regionName,
districtName,
includeOutsidePreElection: true);
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase,
ElectionType = electionType,
DistrictName = districtName,
DistrictCode = target.DistrictCode,
RegionName = regionName,
ElectionDistrictName = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
Candidates = Array.Empty<CandidateEntry>(),
TotalExpectedVotes = 0,
TurnoutVotes = 0,
CountedVotesFromApi = null,
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = DateTimeOffset.Now,
HistoricalTurnoutHistory = history.TurnoutHistory,
HistoricalWinnerHistory = history.WinnerHistory
};
}
private static string NormalizeDistrictKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -2697,9 +3123,17 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private static bool SupportsApiDistrictOptions(string electionType)
{
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) ||
return string.Equals(electionType, "국회의원", StringComparison.Ordinal) ||
string.Equals(electionType, "광역단체장", StringComparison.Ordinal) ||
string.Equals(electionType, "교육감", StringComparison.Ordinal) ||
string.Equals(electionType, "기초단체장", StringComparison.Ordinal);
string.Equals(electionType, "광역의원", StringComparison.Ordinal) ||
string.Equals(electionType, "기초단체장", StringComparison.Ordinal) ||
string.Equals(electionType, "기초의원", StringComparison.Ordinal);
}
private static bool IsBasicCouncilElectionType(string electionType)
{
return string.Equals(electionType, "기초의원", StringComparison.Ordinal);
}
private static bool SupportsApiRefresh(BroadcastPhase phase, string electionType)
@@ -2740,6 +3174,34 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
: trimmedRegion;
}
private static bool MatchesConfiguredRegion(string? regionName, ISet<string> configuredRegions)
{
if (configuredRegions.Count == 0)
{
return true;
}
return GetNormalizedRegionKeys(regionName).Any(configuredRegions.Contains);
}
private static IEnumerable<string> GetNormalizedRegionKeys(string? regionName)
{
var normalizedRegion = NormalizeConfiguredRegion(regionName);
if (string.IsNullOrWhiteSpace(normalizedRegion))
{
yield break;
}
yield return normalizedRegion;
if (string.Equals(normalizedRegion, "전남광주", StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalizedRegion, "광주전남", StringComparison.OrdinalIgnoreCase))
{
yield return "광주";
yield return "전남";
}
}
private bool IsStationRegionOverviewMode =>
string.Equals(SelectedDistrictViewName, StationRegionOverviewOptionValue, StringComparison.Ordinal);

View File

@@ -226,7 +226,8 @@ public sealed class MainViewModel : ObservableObject
nameof(CutListVisibility),
nameof(SettingsVisibility),
nameof(LogVisibility),
nameof(CurrentPageTitle));
nameof(CurrentPageTitle),
nameof(HeaderStatus));
}
}
}
@@ -238,7 +239,8 @@ public sealed class MainViewModel : ObservableObject
AppPage.Bottom => "하단",
AppPage.VideoWall => "비디오월",
AppPage.PreElectionData => "사전데이터",
AppPage.Data => "데이터",
AppPage.TurnoutData => "투표데이터",
AppPage.CountingData or AppPage.Data => "개표데이터",
AppPage.CutList => "컷리스트",
AppPage.Settings => "설정",
AppPage.Log => "로그",
@@ -281,7 +283,7 @@ public sealed class MainViewModel : ObservableObject
public Visibility PreElectionDataVisibility => CurrentPage == AppPage.PreElectionData ? Visibility.Visible : Visibility.Collapsed;
public Visibility DataVisibility => CurrentPage == AppPage.Data ? Visibility.Visible : Visibility.Collapsed;
public Visibility DataVisibility => IsLiveDataPage(CurrentPage) ? Visibility.Visible : Visibility.Collapsed;
public Visibility CutListVisibility => CurrentPage == AppPage.CutList ? Visibility.Visible : Visibility.Collapsed;
@@ -504,19 +506,24 @@ public sealed class MainViewModel : ObservableObject
public void Navigate(string tag)
{
CurrentPage = tag switch
var targetPage = tag switch
{
"normal" when IsGeneralOperationMode => AppPage.Normal,
"top-left" when IsGeneralOperationMode => AppPage.TopLeft,
"bottom" when IsGeneralOperationMode => AppPage.Bottom,
"videowall" when IsVideoWallOperationMode => AppPage.VideoWall,
"pre-election-data" => AppPage.PreElectionData,
"data" => AppPage.Data,
"turnout-data" => AppPage.TurnoutData,
"counting-data" => AppPage.CountingData,
"data" => Data.IsPreElectionPhase ? AppPage.TurnoutData : AppPage.CountingData,
"cut-list" => AppPage.CutList,
"settings" => AppPage.Settings,
"log" => AppPage.Log,
_ => GetDefaultPage()
};
CurrentPage = targetPage;
SyncBroadcastPhaseForLiveDataPage(targetPage);
}
public bool IsPageAvailable(AppPage page)
@@ -529,6 +536,26 @@ public sealed class MainViewModel : ObservableObject
};
}
private static bool IsLiveDataPage(AppPage page)
{
return page is AppPage.TurnoutData or AppPage.CountingData or AppPage.Data;
}
private void SyncBroadcastPhaseForLiveDataPage(AppPage page)
{
var targetPhase = page switch
{
AppPage.TurnoutData => BroadcastPhase.PreElection,
AppPage.CountingData or AppPage.Data => BroadcastPhase.Counting,
_ => (BroadcastPhase?)null
};
if (targetPhase is { } phase)
{
Data.ApplyBroadcastPhase(phase);
}
}
public async Task<bool> HasRestorableStateAsync()
{
return await _stateStore.LoadAsync() is not null;
@@ -637,6 +664,13 @@ public sealed class MainViewModel : ObservableObject
public void ApplyBroadcastPhase(BroadcastPhase phase)
{
Data.ApplyBroadcastPhase(phase);
if (IsLiveDataPage(CurrentPage))
{
CurrentPage = phase == BroadcastPhase.PreElection
? AppPage.TurnoutData
: AppPage.CountingData;
}
OnPropertyChanged(nameof(HeaderStatus));
}
@@ -1208,7 +1242,7 @@ public sealed class MainViewModel : ObservableObject
continue;
}
cut.DurationSeconds = Math.Max(1, Math.Round(duration, 1, MidpointRounding.AwayFromZero));
cut.DurationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(duration, template);
}
}
@@ -1317,9 +1351,13 @@ public sealed class MainViewModel : ObservableObject
Description = template?.Description ?? item.Description,
Channel = item.Channel,
RequiresImage = template?.RequiresImage ?? item.RequiresImage,
DefaultCutDurationSeconds = item.DefaultCutDurationSeconds > 0
DefaultCutDurationSeconds = template is null
? item.DefaultCutDurationSeconds
: template?.Cuts.FirstOrDefault()?.DurationSeconds ?? item.DefaultCutDurationSeconds,
: ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
item.DefaultCutDurationSeconds > 0
? item.DefaultCutDurationSeconds
: template.Cuts.FirstOrDefault()?.DurationSeconds ?? item.DefaultCutDurationSeconds,
template),
TotalCuts = template?.Cuts.Count ?? item.TotalCuts,
RegionScope = item.RegionScope,
ScheduleElectionType = item.ScheduleElectionType,

View File

@@ -162,12 +162,12 @@ public sealed class PreElectionHistoryWinnerEditRowViewModel : ObservableObject
return OtherPreview;
}
if (normalizedParty is "국민의힘" or "미래통합당" or "자유한국당" or "새누리당" or "한나라당" or "민주자유당")
if (normalizedParty is "국민의힘" or "국힘" or "미래통합당" or "자유한국당" or "새누리당" or "한나라당" or "신한국당" or "민주자유당")
{
return ConservativePreview;
}
if (normalizedParty is "더불어민주당" or "민주당" or "새천년민주당" or "열린우리당" or "민주통합당" or "새정치민주연합")
if (normalizedParty is "더불어민주당" or "민주당" or "민주당1991" or "민주당2000" or "민주당2008" or "새정치국민회의" or "새천년민주당" or "열린민주당" or "열린우리당" or "민주통합당" or "새정치민주연합")
{
return DemocraticPreview;
}

View File

@@ -35,7 +35,10 @@ public sealed class SettingsViewModel : ObservableObject
if (station == SelectedStation && args.PropertyName is nameof(StationFilterItemViewModel.VideoWallLayoutPreset) or nameof(StationFilterItemViewModel.VideoWallLayoutSummary))
{
OnPropertyChanged(nameof(SelectedStationVideoWallLayoutPreset), nameof(SelectedStationVideoWallLayoutSummary));
OnPropertyChanged(
nameof(SelectedStationVideoWallLayoutPreset),
nameof(SelectedStationVideoWallLayoutPresetSelection),
nameof(SelectedStationVideoWallLayoutSummary));
}
};
}
@@ -63,6 +66,7 @@ public sealed class SettingsViewModel : ObservableObject
nameof(SelectedStationRegions),
nameof(SelectedStationRegionSummary),
nameof(SelectedStationVideoWallLayoutPreset),
nameof(SelectedStationVideoWallLayoutPresetSelection),
nameof(SelectedStationVideoWallLayoutSummary));
}
}
@@ -70,8 +74,8 @@ public sealed class SettingsViewModel : ObservableObject
public string ImageRootPath
{
get => _imageRootPath;
set => SetProperty(ref _imageRootPath, TornadoPathResolver.NormalizeConfiguredPath(value));
get => TornadoPathResolver.GetDefaultT3CutPath();
set => SetProperty(ref _imageRootPath, TornadoPathResolver.GetDefaultT3CutPath());
}
public bool IsDebugFeaturesEnabled
@@ -100,7 +104,22 @@ public sealed class SettingsViewModel : ObservableObject
}
SelectedStation.VideoWallLayoutPreset = value;
OnPropertyChanged(nameof(SelectedStationVideoWallLayoutPreset), nameof(SelectedStationVideoWallLayoutSummary));
OnPropertyChanged(
nameof(SelectedStationVideoWallLayoutPreset),
nameof(SelectedStationVideoWallLayoutPresetSelection),
nameof(SelectedStationVideoWallLayoutSummary));
}
}
public VideoWallLayoutPreset? SelectedStationVideoWallLayoutPresetSelection
{
get => SelectedStationVideoWallLayoutPreset;
set
{
if (value.HasValue)
{
SelectedStationVideoWallLayoutPreset = value.Value;
}
}
}

View File

@@ -25,7 +25,7 @@ Use this skill to make the smallest safe change to a cut-related workflow, then
- Use [validation-workflow.md](references/validation-workflow.md) for command selection.
- For a scoped live pass, prefer `scripts/validate-cut.ps1`.
- For scene-level snapshots or raw object checks, use `tools/KarismaTcpProbe` directly.
- If live Karisma or `T3_Cut` is unavailable, still run the build and document the missing external dependency.
- If live Karisma or the fixed `D:\Elect2026\T3_Cut` root is unavailable, still run the build and document the missing external dependency.
4. Report the result in operational terms.
- Name the files changed.
@@ -35,7 +35,7 @@ Use this skill to make the smallest safe change to a cut-related workflow, then
## Repo Notes
- Treat the repo as the source of truth for cut metadata and validation helpers.
- Treat `T3_Cut` as an external dependency that may contain the real scene or asset causing the issue.
- Treat `D:\Elect2026\T3_Cut` as the fixed external dependency that may contain the real scene or asset causing the issue.
- Prefer targeted validation with a template or cut filter instead of sweeping the whole catalog unless the user asks for a broad audit.
- Reuse existing `tools/KarismaTcpProbe/scene-ops/*.json` fixtures when they match the symptom instead of inventing a new validation format.

View File

@@ -19,7 +19,7 @@ Use this reference to decide where a cut-related change belongs.
- `Tornado3_2026Election/Services/FormatCatalogService.cs`: template and cut catalog.
- `Tornado3_2026Election/Services/KarismaSceneResolver.cs`: resolve the actual `.tscn` or `_loop.tscn` path.
- `Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs`: scene-variable discovery and lookup cache.
- `Tornado3_2026Election/Services/TornadoPathResolver.cs`: default and normalized `T3_Cut` path handling.
- `Tornado3_2026Election/Services/TornadoPathResolver.cs`: fixed `D:\Elect2026\T3_Cut` path handling.
- `Tornado3_2026Election/Services/CutThumbnailAssetCatalog.cs`: project thumbnail asset locations.
## Runtime apply logic

View File

@@ -10,11 +10,10 @@ dotnet build Tornado3_2026Election.slnx
## 2. Run scoped live validation for a cut or template
Use the local wrapper when Karisma and `T3_Cut` are available.
Use the local wrapper when Karisma and `D:\Elect2026\T3_Cut` are available. The root is fixed; do not pass a custom image root.
```powershell
powershell -ExecutionPolicy Bypass -File plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1 `
-ImageRootPath 'C:\Path\To\T3_Cut' `
-Filter '1-2위_ani_광역단체장'
```
@@ -31,7 +30,7 @@ Use this when the problem is visual and you already know the exact `.tscn` scene
```powershell
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
--save-scene-image `
--scene 'C:\Path\To\T3_Cut\SomeScene.tscn' `
--scene 'D:\Elect2026\T3_Cut\SomeScene.tscn' `
--output artifacts\scene-captures\some-scene.png
```
@@ -42,7 +41,7 @@ Use this when the issue is about values, visibility, or style updates for known
```powershell
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
--validate-scene-values `
--scene 'C:\Path\To\T3_Cut\SomeScene.tscn' `
--scene 'D:\Elect2026\T3_Cut\SomeScene.tscn' `
--operations tools/KarismaTcpProbe/scene-ops/1-2위_ani_광역단체장_style.json `
--output artifacts\scene-validation\style.md
```
@@ -52,6 +51,5 @@ dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
```powershell
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
--inspect-tscn-folder `
--root 'C:\Path\To\T3_Cut' `
--output artifacts\scene-inspection\inspection.md
```

View File

@@ -10,6 +10,7 @@ param(
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..\..")).Path
$probeProject = Join-Path $repoRoot "tools\KarismaTcpProbe\KarismaTcpProbe.csproj"
$fixedImageRootPath = "D:\Elect2026\T3_Cut"
if ([string]::IsNullOrWhiteSpace($OutputPath))
{
@@ -27,11 +28,6 @@ $commandArgs = @(
"--between-delay-ms", $BetweenDelayMs.ToString()
)
if (-not [string]::IsNullOrWhiteSpace($ImageRootPath))
{
$commandArgs += @("--image-root", $ImageRootPath)
}
if (-not [string]::IsNullOrWhiteSpace($Filter))
{
$commandArgs += @("--filter", $Filter)
@@ -49,7 +45,12 @@ if ($IncludeVideoWall)
Write-Host "Running live-cut validation..."
Write-Host "Repo Root : $repoRoot"
Write-Host "Image Root: $fixedImageRootPath"
Write-Host "Output : $OutputPath"
if (-not [string]::IsNullOrWhiteSpace($ImageRootPath))
{
Write-Host "ImageRootPath parameter ignored; fixed root is used."
}
if (-not [string]::IsNullOrWhiteSpace($Filter))
{
Write-Host "Filter : $Filter"

View File

@@ -431,6 +431,8 @@ internal sealed class CatalogEventHandler : KAEventHandler
internal sealed class CatalogOptions
{
private const string FixedT3CutPath = @"D:\Elect2026\T3_Cut";
public string Host { get; private set; }
public int Port { get; private set; }
public TimeSpan Timeout { get; private set; }
@@ -444,11 +446,7 @@ internal sealed class CatalogOptions
Host = "127.0.0.1";
Port = 30001;
Timeout = TimeSpan.FromSeconds(5);
RootPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"Tornado3 Data",
"T3_Cut",
"T3_Cut");
RootPath = FixedT3CutPath;
OutputPath = Path.Combine(Environment.CurrentDirectory, "SCENE_OBJECT_CATALOG.md");
SceneFilter = string.Empty;
MaxScenes = 0;
@@ -474,7 +472,7 @@ internal sealed class CatalogOptions
index++;
break;
case "--root" when index + 1 < args.Length:
options.RootPath = Path.GetFullPath(args[++index]);
index++;
break;
case "--output" when index + 1 < args.Length:
options.OutputPath = Path.GetFullPath(args[++index]);

View File

@@ -58,7 +58,7 @@ internal static class CurrentApiCutDiagnostics
Console.WriteLine($"- Station: {(options.AllStations ? "ALL" : options.StationId)}");
Console.WriteLine($"- Region Scope: {options.RegionScope}");
Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}");
Console.WriteLine($"- Simulated Sends: {(options.SimulateSend ? options.SendLimit.ToString() : "off")}");
Console.WriteLine($"- Send Mode: {ResolveSendModeLabel(options)}");
Console.WriteLine($"- Output: {options.OutputPath}");
var stationCatalog = new StationCatalogService().GetAll();
@@ -80,6 +80,9 @@ internal static class CurrentApiCutDiagnostics
.Where(template => string.IsNullOrWhiteSpace(options.Filter) ||
template.Id.Contains(options.Filter, StringComparison.OrdinalIgnoreCase) ||
template.Name.Contains(options.Filter, StringComparison.OrdinalIgnoreCase))
.Where(template => string.IsNullOrWhiteSpace(options.ExcludeFilter) ||
(!template.Id.Contains(options.ExcludeFilter, StringComparison.OrdinalIgnoreCase) &&
!template.Name.Contains(options.ExcludeFilter, StringComparison.OrdinalIgnoreCase)))
.ToArray();
if (options.TemplateLimit is int templateLimit && templateLimit > 0)
@@ -99,7 +102,17 @@ internal static class CurrentApiCutDiagnostics
using var apiClient = new SbsElectionApiClient();
var logService = new LogService();
var adapter = options.SimulateSend ? new MockTornado3Adapter(logService) : null;
var preElectionHistoryService = new PreElectionHistoryService(logService);
ITornado3Adapter? adapter;
try
{
adapter = CreateSendAdapter(options, logService);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
return 1;
}
var districtCache = new Dictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>>(StringComparer.Ordinal);
var results = new List<CurrentApiCutDiagnosticResult>();
var simulatedSendCount = 0;
@@ -109,7 +122,17 @@ internal static class CurrentApiCutDiagnostics
foreach (var template in formats)
{
var electionType = ResolveScheduleElectionType(template.Name, phase, options.DefaultElectionType);
var districts = await GetDistrictsAsync(apiClient, districtCache, electionType).ConfigureAwait(false);
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts;
try
{
districts = await GetDistrictsAsync(apiClient, districtCache, electionType, station).ConfigureAwait(false);
}
catch (Exception ex)
{
results.Add(CurrentApiCutDiagnosticResult.DistrictLoadFailed(station, template, phase, electionType, ex.Message));
continue;
}
var targets = ResolveTargets(districts, station, options)
.ToArray();
@@ -136,11 +159,24 @@ internal static class CurrentApiCutDiagnostics
try
{
var refreshResult = await apiClient
.RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
.ConfigureAwait(false);
var snapshot = CreateSnapshot(phase, electionType, refreshResult);
PopulateDataFields(result, snapshot, refreshResult.SourcePath);
ElectionDataSnapshot snapshot;
if (UsesStoredPreElectionHistory(template))
{
snapshot = CreateStoredPreElectionHistorySnapshot(
phase,
electionType,
target,
preElectionHistoryService);
PopulateDataFields(result, snapshot, "stored pre-election history");
}
else
{
var refreshResult = await apiClient
.RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
.ConfigureAwait(false);
snapshot = CreateSnapshot(phase, electionType, refreshResult);
PopulateDataFields(result, snapshot, refreshResult.SourcePath);
}
if (!ValidateSnapshotForFormat(template, snapshot, out var validationError, out var warning))
{
@@ -152,8 +188,10 @@ internal static class CurrentApiCutDiagnostics
{
await SimulateSendAsync(adapter, station, template, snapshot, options.ImageRootPath).ConfigureAwait(false);
simulatedSendCount++;
result.Status = "sent-mock";
result.Detail = "validated and mock send completed";
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
result.Detail = options.LiveSend
? "validated and live send completed"
: "validated and mock send completed";
result.Warning = warning;
}
else
@@ -174,6 +212,11 @@ internal static class CurrentApiCutDiagnostics
}
}
if (adapter is IDisposable disposable)
{
disposable.Dispose();
}
WriteReports(options, results);
PrintSummary(results, options.OutputPath);
@@ -182,15 +225,53 @@ internal static class CurrentApiCutDiagnostics
: 0;
}
private static ITornado3Adapter? CreateSendAdapter(CurrentApiCutDiagnosticsOptions options, LogService logService)
{
if (!options.SimulateSend)
{
return null;
}
if (!options.LiveSend)
{
return new MockTornado3Adapter(logService);
}
var cutDebugStateStore = new CutDebugStateStore();
if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) ||
!adapter.IsLiveCg)
{
throw new InvalidOperationException("Karisma adapter is not available. Live send cannot continue.");
}
return adapter;
}
private static string ResolveSendModeLabel(CurrentApiCutDiagnosticsOptions options)
{
if (!options.SimulateSend)
{
return "off";
}
return options.LiveSend
? $"live ({options.SendLimit})"
: $"mock ({options.SendLimit})";
}
private static async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetDistrictsAsync(
SbsElectionApiClient apiClient,
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
string electionType)
string electionType,
BroadcastStationProfile station)
{
if (!districtCache.TryGetValue(electionType, out var districts))
var cacheKey = $"{electionType}|{string.Join(",", station.RegionFilters)}";
if (!districtCache.TryGetValue(cacheKey, out var districts))
{
districts = await apiClient.GetDistrictOptionsAsync(electionType, CancellationToken.None).ConfigureAwait(false);
districtCache[electionType] = districts;
districts = await apiClient
.GetDistrictOptionsAsync(electionType, station.RegionFilters, CancellationToken.None)
.ConfigureAwait(false);
districtCache[cacheKey] = districts;
}
return districts;
@@ -265,6 +346,42 @@ internal static class CurrentApiCutDiagnostics
};
}
private static ElectionDataSnapshot CreateStoredPreElectionHistorySnapshot(
BroadcastPhase phase,
string electionType,
SbsElectionApiClient.DistrictSelectionOption target,
PreElectionHistoryService preElectionHistoryService)
{
var regionName = target.RegionName ?? string.Empty;
var districtName = !string.IsNullOrWhiteSpace(target.DistrictName)
? target.DistrictName
: !string.IsNullOrWhiteSpace(target.DisplayName)
? target.DisplayName
: regionName;
var history = preElectionHistoryService.ResolveHistory(electionType, regionName, districtName);
return new ElectionDataSnapshot
{
BroadcastPhase = phase,
ElectionType = electionType,
DistrictName = districtName,
DistrictCode = target.DistrictCode,
RegionName = regionName,
ElectionDistrictName = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
Candidates = Array.Empty<CandidateEntry>(),
TotalExpectedVotes = 0,
TurnoutVotes = 0,
CountedVotesFromApi = null,
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = DateTimeOffset.Now,
HistoricalTurnoutHistory = history?.TurnoutHistory.OrderBy(entry => entry.Year).ToArray()
?? Array.Empty<PreElectionHistoricalTurnoutEntry>(),
HistoricalWinnerHistory = history?.WinnerHistory.OrderBy(entry => entry.ElectionOrder).ToArray()
?? Array.Empty<PreElectionHistoricalWinnerEntry>()
};
}
private static async Task SimulateSendAsync(
ITornado3Adapter adapter,
BroadcastStationProfile station,
@@ -274,11 +391,84 @@ internal static class CurrentApiCutDiagnostics
{
foreach (var cut in template.Cuts)
{
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
Exception? lastException = null;
for (var attempt = 1; attempt <= 3; attempt++)
{
try
{
await SendSingleCutAsync(adapter, station, template, cut, snapshot, imageRootPath).ConfigureAwait(false);
lastException = null;
break;
}
catch (Exception ex) when (attempt < 3)
{
lastException = ex;
await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false);
await Task.Delay(750, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
lastException = ex;
}
}
if (lastException is not null)
{
throw lastException;
}
}
}
private static async Task SendSingleCutAsync(
ITornado3Adapter adapter,
BroadcastStationProfile station,
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
string imageRootPath)
{
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "connect");
try
{
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, imageRootPath, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "apply");
await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "prepare");
await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
await adapter.OutAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "take");
}
finally
{
await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false);
if (adapter.IsLiveCg)
{
await Task.Delay(250, CancellationToken.None).ConfigureAwait(false);
}
}
}
private static async Task TryOutAsync(ITornado3Adapter adapter, BroadcastChannel channel)
{
try
{
await adapter.OutAsync(channel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "out");
}
catch
{
if (!adapter.IsLiveCg)
{
throw;
}
}
}
private static void ThrowIfAdapterErrored(ITornado3Adapter adapter, string action)
{
if (adapter.State == TornadoConnectionState.Error)
{
throw new InvalidOperationException($"Karisma live send failed during {action}.");
}
}
@@ -290,6 +480,30 @@ internal static class CurrentApiCutDiagnostics
{
warning = string.Empty;
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
{
if (snapshot.HistoricalTurnoutHistory.Count == 0)
{
errorMessage = "historical turnout data is empty";
return false;
}
errorMessage = string.Empty;
return true;
}
if (ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name))
{
if (snapshot.HistoricalWinnerHistory.Count == 0)
{
errorMessage = "historical winner data is empty";
return false;
}
errorMessage = string.Empty;
return true;
}
if (IsTurnoutTemplate(template) &&
(snapshot.TurnoutVotes <= 0 || snapshot.TurnoutRate <= 0))
{
@@ -355,6 +569,12 @@ internal static class CurrentApiCutDiagnostics
return true;
}
private static bool UsesStoredPreElectionHistory(FormatTemplateDefinition template)
{
return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) ||
ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name);
}
private static string JoinWarning(string current, string next)
{
if (string.IsNullOrWhiteSpace(current))
@@ -443,15 +663,27 @@ internal static class CurrentApiCutDiagnostics
return "교육감";
}
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal) ||
resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
if (resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
{
return "기초의원";
}
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal))
{
return "기초단체장";
}
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal) ||
resolvedFormatName.Contains("광역의원", StringComparison.Ordinal) ||
resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
if (resolvedFormatName.Contains("광역의원", StringComparison.Ordinal))
{
return "광역의원";
}
if (resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
{
return "국회의원";
}
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal))
{
return "광역단체장";
}
@@ -578,8 +810,12 @@ internal static class CurrentApiCutDiagnostics
public string Filter { get; init; } = string.Empty;
public string ExcludeFilter { get; init; } = string.Empty;
public bool SimulateSend { get; init; } = true;
public bool LiveSend { get; init; }
public int SendLimit { get; init; } = 24;
public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath();
@@ -601,9 +837,10 @@ internal static class CurrentApiCutDiagnostics
var includeVideoWall = false;
int? templateLimit = null;
var filter = string.Empty;
var excludeFilter = string.Empty;
var simulateSend = true;
var liveSend = false;
var sendLimit = 24;
var imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
var outputPath = Path.Combine(
"artifacts",
"current-api-cut-diagnostics",
@@ -644,8 +881,16 @@ internal static class CurrentApiCutDiagnostics
case "--filter":
filter = NextValue();
break;
case "--exclude-filter":
excludeFilter = NextValue();
break;
case "--no-send":
simulateSend = false;
liveSend = false;
break;
case "--live-send":
simulateSend = true;
liveSend = true;
break;
case "--send-limit":
if (int.TryParse(NextValue(), out var parsedSendLimit))
@@ -654,7 +899,7 @@ internal static class CurrentApiCutDiagnostics
}
break;
case "--image-root":
imageRootPath = TornadoPathResolver.NormalizeConfiguredPath(NextValue());
_ = NextValue();
break;
case "--output":
outputPath = NextValue();
@@ -675,9 +920,11 @@ internal static class CurrentApiCutDiagnostics
IncludeVideoWall = includeVideoWall,
TemplateLimit = templateLimit,
Filter = filter,
ExcludeFilter = excludeFilter,
SimulateSend = simulateSend,
LiveSend = liveSend,
SendLimit = sendLimit,
ImageRootPath = imageRootPath,
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
OutputPath = outputPath,
DefaultElectionType = defaultElectionType
};
@@ -751,5 +998,25 @@ internal static class CurrentApiCutDiagnostics
Detail = "no matching schedule regions"
};
}
public static CurrentApiCutDiagnosticResult DistrictLoadFailed(
BroadcastStationProfile station,
FormatTemplateDefinition template,
BroadcastPhase phase,
string electionType,
string detail)
{
return new CurrentApiCutDiagnosticResult
{
Station = station.Id,
Channel = template.RecommendedChannel.ToString(),
TemplateId = template.Id,
TemplateName = template.Name,
Phase = phase.ToString(),
ElectionType = electionType,
Status = "api-or-send-failed",
Detail = detail
};
}
}
}

View File

@@ -41,8 +41,10 @@
<Compile Include="..\..\Tornado3_2026Election\Services\CutAppearancePolicyCatalog.cs" Link="AppSource\Services\CutAppearancePolicyCatalog.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\FormatCatalogService.cs" Link="AppSource\Services\FormatCatalogService.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaChartCellUpdate.cs" Link="AppSource\Services\KarismaChartCellUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaPositionUpdate.cs" Link="AppSource\Services\KarismaPositionUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolutionReader.cs" Link="AppSource\Services\KarismaSceneResolutionReader.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolver.cs" Link="AppSource\Services\KarismaSceneResolver.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneVariableCatalog.cs" Link="AppSource\Services\KarismaSceneVariableCatalog.cs" />
@@ -52,6 +54,8 @@
<Compile Include="..\..\Tornado3_2026Election\Services\LogService.cs" Link="AppSource\Services\LogService.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\MockTornado3Adapter.cs" Link="AppSource\Services\MockTornado3Adapter.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\PartyColorCatalog.cs" Link="AppSource\Services\PartyColorCatalog.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\PreElectionHistoryService.cs" Link="AppSource\Services\PreElectionHistoryService.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\ScheduleTemplatePolicy.cs" Link="AppSource\Services\ScheduleTemplatePolicy.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\SbsElectionApiClient.cs" Link="AppSource\Services\SbsElectionApiClient.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\StationCatalogService.cs" Link="AppSource\Services\StationCatalogService.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\TornadoManager.cs" Link="AppSource\Services\TornadoManager.cs" />

View File

@@ -19,6 +19,7 @@ internal static class LiveCutValidation
Console.WriteLine($"- Image Root: {options.ImageRootPath}");
Console.WriteLine($"- Output: {options.OutputPath}");
Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}");
Console.WriteLine($"- Capture Mode: {options.CaptureMode}");
var logService = new LogService();
var cutDebugStateStore = new CutDebugStateStore();
@@ -70,6 +71,9 @@ internal static class LiveCutValidation
CutName = item.Cut.Name,
Channel = item.Template.RecommendedChannel.ToString(),
Phase = preElection ? BroadcastPhase.PreElection.ToString() : BroadcastPhase.Counting.ToString(),
CaptureMode = options.CaptureMode.ToString(),
CaptureComparable = options.CaptureMode == LiveCutCaptureMode.Scene ||
(pgmWindow is not null && item.Template.RecommendedChannel != BroadcastChannel.VideoWall),
OutputVisibleInPgm = pgmWindow is not null &&
item.Template.RecommendedChannel != BroadcastChannel.VideoWall
};
@@ -81,8 +85,22 @@ internal static class LiveCutValidation
await OutAllAsync(adapter).ConfigureAwait(false);
await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
var snapshotA = CreateSnapshot(item.Template.Name, index, variant: 0, preElection, options.SwapTopTwoCandidates);
var snapshotB = CreateSnapshot(item.Template.Name, index, variant: 1, preElection, options.SwapTopTwoCandidates);
var snapshotA = CreateSnapshot(
item.Template.Name,
index,
variant: 0,
preElection,
options.SwapTopTwoCandidates,
options.CycleTopThreeCandidates,
options.StressTopRankValues);
var snapshotB = CreateSnapshot(
item.Template.Name,
index,
variant: 1,
preElection,
options.SwapTopTwoCandidates,
options.CycleTopThreeCandidates,
options.StressTopRankValues);
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotA, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
@@ -90,7 +108,14 @@ internal static class LiveCutValidation
await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false);
result.CaptureAPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_A.png");
result.HashA = CapturePgm(pgmWindow, result.CaptureAPath, !result.OutputVisibleInPgm);
result.HashA = await CaptureValidationImageAsync(
adapter,
pgmWindow,
item,
options,
result.CaptureAPath,
result.OutputVisibleInPgm,
CancellationToken.None).ConfigureAwait(false);
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotB, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
@@ -98,11 +123,18 @@ internal static class LiveCutValidation
await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false);
result.CaptureBPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_B.png");
result.HashB = CapturePgm(pgmWindow, result.CaptureBPath, !result.OutputVisibleInPgm);
result.HashB = await CaptureValidationImageAsync(
adapter,
pgmWindow,
item,
options,
result.CaptureBPath,
result.OutputVisibleInPgm,
CancellationToken.None).ConfigureAwait(false);
result.VisualChanged = !string.Equals(result.HashA, result.HashB, StringComparison.OrdinalIgnoreCase);
result.Success = true;
result.Detail = result.OutputVisibleInPgm
? (result.VisualChanged ? "A/B capture changed" : "A/B capture hash identical")
result.Detail = result.CaptureComparable
? (result.VisualChanged ? $"{options.CaptureMode} A/B capture changed" : $"{options.CaptureMode} A/B capture hash identical")
: "VideoWall output is not visible in the current PGM window";
}
catch (Exception exception)
@@ -145,7 +177,7 @@ internal static class LiveCutValidation
var successCount = results.Count(result => result.Success);
var changedCount = results.Count(result => result.Success && result.VisualChanged);
var unchangedCount = results.Count(result => result.Success && result.OutputVisibleInPgm && !result.VisualChanged);
var unchangedCount = results.Count(result => result.Success && result.CaptureComparable && !result.VisualChanged);
var failureCount = results.Count(result => !result.Success);
Console.WriteLine();
@@ -753,6 +785,7 @@ internal static class LiveCutValidation
var value when value.StartsWith("개표율", StringComparison.OrdinalIgnoreCase) => 3,
var value when value.StartsWith("선거구명", StringComparison.OrdinalIgnoreCase) => 4,
var value when value.StartsWith("시도명", StringComparison.OrdinalIgnoreCase) => 4,
var value when value.StartsWith("의석수", StringComparison.OrdinalIgnoreCase) => 5,
var value when value.StartsWith("득표수", StringComparison.OrdinalIgnoreCase) => 5,
var value when value.StartsWith("정당명", StringComparison.OrdinalIgnoreCase) => 6,
var value when value.StartsWith("유확당", StringComparison.OrdinalIgnoreCase) => 7,
@@ -898,7 +931,7 @@ internal static class LiveCutValidation
yield return new CutDebugItemDescriptor($"{prefix}01", CutDebugItemKind.TextValue, "common");
}
foreach (var prefix in new[] { "순위", "기호", "기호텍스트", "후보명", "정당명", "득표수", "득표율", "표차", "득표차", "선거구명", "시도명", "개표율", "투표율" })
foreach (var prefix in new[] { "순위", "기호", "기호텍스트", "후보명", "정당명", "의석수", "득표수", "득표율", "표차", "득표차", "선거구명", "시도명", "개표율", "투표율" })
{
for (var slot = 1; slot <= slotCount; slot++)
{
@@ -945,6 +978,11 @@ internal static class LiveCutValidation
private static int ResolveDebugSlotCount(FormatTemplateDefinition template)
{
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return 6;
}
var source = $"{template.Name} {template.Id}";
var topRankMatch = System.Text.RegularExpressions.Regex.Match(source, @"1-(\d+)위");
if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlots))
@@ -1349,7 +1387,14 @@ internal static class LiveCutValidation
templateName.StartsWith("사전_", StringComparison.Ordinal);
}
private static ElectionDataSnapshot CreateSnapshot(string templateName, int index, int variant, bool preElection, bool swapTopTwoCandidates)
private static ElectionDataSnapshot CreateSnapshot(
string templateName,
int index,
int variant,
bool preElection,
bool swapTopTwoCandidates,
bool cycleTopThreeCandidates = false,
bool stressTopRankValues = false)
{
var metadata = BuildScenarioMetadata(templateName, index, variant);
return new ElectionDataSnapshot
@@ -1360,7 +1405,9 @@ internal static class LiveCutValidation
DistrictCode = metadata.DistrictCode,
RegionName = metadata.RegionName,
ElectionDistrictName = metadata.ElectionDistrictName,
Candidates = preElection ? Array.Empty<CandidateEntry>() : CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates),
Candidates = preElection
? Array.Empty<CandidateEntry>()
: CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates, cycleTopThreeCandidates, stressTopRankValues),
TotalExpectedVotes = metadata.TotalExpectedVotes,
TurnoutVotes = metadata.TurnoutVotes,
CountedVotesFromApi = metadata.CountedVotes,
@@ -1374,11 +1421,27 @@ internal static class LiveCutValidation
};
}
private static IReadOnlyList<CandidateEntry> CreateCandidates(string templateName, ScenarioMetadata metadata, int variant, bool swapTopTwoCandidates)
private static IReadOnlyList<CandidateEntry> CreateCandidates(
string templateName,
ScenarioMetadata metadata,
int variant,
bool swapTopTwoCandidates,
bool cycleTopThreeCandidates,
bool stressTopRankValues)
{
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(templateName))
{
return CreateCouncilSeatCandidates(templateName, metadata, variant, stressTopRankValues);
}
var candidateNames = ResolveCandidateNames(templateName);
var parties = ResolveParties(candidateNames.Length);
var shares = ResolveVoteShares(templateName, candidateNames.Length, variant);
if (stressTopRankValues && variant % 2 == 1)
{
shares = StressTopRankShares(shares);
}
var automaticJudgement = ResolveAutomaticJudgement(templateName);
var identityOrder = Enumerable.Range(0, candidateNames.Length).ToArray();
@@ -1387,6 +1450,11 @@ internal static class LiveCutValidation
(identityOrder[0], identityOrder[1]) = (identityOrder[1], identityOrder[0]);
}
if (cycleTopThreeCandidates && variant % 2 == 1)
{
CycleTopCandidateIdentities(identityOrder);
}
var candidates = new List<CandidateEntry>(candidateNames.Length);
for (var index = 0; index < candidateNames.Length; index++)
{
@@ -1408,6 +1476,99 @@ internal static class LiveCutValidation
return candidates;
}
private static IReadOnlyList<CandidateEntry> CreateCouncilSeatCandidates(
string templateName,
ScenarioMetadata metadata,
int variant,
bool stressTopRankValues)
{
var parties = ResolveParties(6);
var totalSeats = ResolveCouncilSeatPattern(templateName, variant, stressTopRankValues)
.Take(parties.Length)
.ToArray();
var proportionalSeats = ResolveCouncilSeatProportionalPattern(templateName, variant, stressTopRankValues)
.Take(parties.Length)
.ToArray();
var candidates = new List<CandidateEntry>(parties.Length * 2);
for (var partyIndex = 0; partyIndex < parties.Length; partyIndex++)
{
var party = parties[partyIndex];
var proportionalCount = Math.Min(totalSeats[partyIndex], proportionalSeats.ElementAtOrDefault(partyIndex));
var districtCount = Math.Max(0, totalSeats[partyIndex] - proportionalCount);
candidates.Add(CreateCouncilSeatSummaryCandidate($"SEAT:D:{partyIndex + 1:00}", party, partyIndex, districtCount));
if (proportionalCount > 0)
{
candidates.Add(CreateCouncilSeatSummaryCandidate($"SEAT:P:{partyIndex + 1:00}", party, partyIndex, proportionalCount));
}
}
return candidates;
}
private static CandidateEntry CreateCouncilSeatSummaryCandidate(string candidateCode, string party, int partyIndex, int seatCount)
{
return new CandidateEntry
{
CandidateCode = candidateCode,
BallotNumber = (partyIndex + 1).ToString(),
Name = party,
Party = party,
ColorParty = party,
VoteRate = seatCount,
VoteCount = seatCount,
HasImage = false,
ManualJudgement = CandidateJudgement.None,
AutomaticJudgement = CandidateJudgement.Elected
};
}
private static int[] ResolveCouncilSeatPattern(string templateName, int variant, bool stressTopRankValues)
{
if (templateName.Contains("기초의원", StringComparison.Ordinal))
{
return stressTopRankValues && variant % 2 == 1
? [34, 21, 13, 7, 3, 1]
: [29, 25, 9, 5, 2, 1];
}
return stressTopRankValues && variant % 2 == 1
? [22, 17, 9, 4, 2, 1]
: [18, 15, 6, 3, 1, 1];
}
private static int[] ResolveCouncilSeatProportionalPattern(string templateName, int variant, bool stressTopRankValues)
{
if (templateName.Contains("기초의원", StringComparison.Ordinal))
{
return stressTopRankValues && variant % 2 == 1
? [5, 3, 2, 1, 0, 0]
: [4, 3, 1, 0, 0, 0];
}
return stressTopRankValues && variant % 2 == 1
? [4, 3, 2, 1, 0, 0]
: [3, 3, 1, 0, 0, 0];
}
private static void CycleTopCandidateIdentities(int[] identityOrder)
{
var topCount = Math.Min(3, identityOrder.Length);
if (topCount < 2)
{
return;
}
var first = identityOrder[0];
for (var index = 0; index < topCount - 1; index++)
{
identityOrder[index] = identityOrder[index + 1];
}
identityOrder[topCount - 1] = first;
}
private static IReadOnlyList<PreElectionHistoricalTurnoutEntry> CreateHistoricalTurnout(ScenarioMetadata metadata, int variant)
{
// The historical turnout scenes currently ship with a fixed line/marker drawing in the source tscn.
@@ -1549,6 +1710,36 @@ internal static class LiveCutValidation
return shares;
}
private static double[] StressTopRankShares(double[] shares)
{
var stressed = shares.ToArray();
if (stressed.Length >= 3)
{
var trailingTotal = Math.Round(stressed.Skip(3).Sum(), 1, MidpointRounding.AwayFromZero);
var topTotal = Math.Max(0d, 100d - trailingTotal);
stressed[0] = Math.Round(topTotal * 0.46d, 1, MidpointRounding.AwayFromZero);
stressed[1] = Math.Round(topTotal * 0.32d, 1, MidpointRounding.AwayFromZero);
stressed[2] = Math.Round(topTotal - stressed[0] - stressed[1], 1, MidpointRounding.AwayFromZero);
}
else if (stressed.Length == 2)
{
stressed[0] = 62.7d;
stressed[1] = 37.3d;
}
else if (stressed.Length == 1)
{
stressed[0] = 99.9d;
}
var delta = Math.Round(100d - stressed.Sum(), 1, MidpointRounding.AwayFromZero);
if (stressed.Length > 0)
{
stressed[0] = Math.Round(stressed[0] + delta, 1, MidpointRounding.AwayFromZero);
}
return stressed;
}
private static CandidateJudgement ResolveAutomaticJudgement(string templateName)
{
if (templateName.Contains("당선", StringComparison.Ordinal))
@@ -1599,6 +1790,11 @@ internal static class LiveCutValidation
return new ScenarioMetadata("광역의원", "44001", "충남 제1선거구", "충남", "충남도의원 제1선거구", totalExpectedVotes / 3, turnoutVotes / 3, countedVotes / 3, countedRate, 55.4 + (seed % 3) + (variant * 1.6), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
}
if (templateName.Contains("보궐선거", StringComparison.Ordinal))
{
return new ScenarioMetadata("국회의원", "2411502", "평택 을", "경기", "평택 을", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 56.5 + (seed % 4) + (variant * 1.5), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
}
return new ScenarioMetadata("광역단체장", "44", "충청남도", "충남", "충남도지사", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 57.3 + (seed % 5) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
}
@@ -1615,6 +1811,57 @@ internal static class LiveCutValidation
return ComputeSha256(outputPath);
}
private static async Task<string> CaptureValidationImageAsync(
ITornado3Adapter adapter,
PgmWindow? pgmWindow,
LiveCutWorkItem item,
LiveCutValidationOptions options,
string outputPath,
bool outputVisibleInPgm,
CancellationToken cancellationToken)
{
if (options.CaptureMode == LiveCutCaptureMode.Pgm)
{
return CapturePgm(pgmWindow, outputPath, !outputVisibleInPgm);
}
if (adapter is not KarismaTornado3Adapter karismaAdapter)
{
throw new InvalidOperationException("Scene capture mode requires the Karisma adapter.");
}
var (width, height) = ResolveSceneCaptureSize(item.Template);
await karismaAdapter.SavePendingSceneImageAsync(
item.Template.RecommendedChannel,
outputPath,
width,
height,
frame: -1,
cancellationToken)
.ConfigureAwait(false);
return ComputeSha256(outputPath);
}
private static (int Width, int Height) ResolveSceneCaptureSize(FormatTemplateDefinition template)
{
var sourceWidth = template.SceneWidth.GetValueOrDefault(1920);
var sourceHeight = template.SceneHeight.GetValueOrDefault(1080);
if (sourceWidth <= 0 || sourceHeight <= 0)
{
return (1280, 720);
}
const int maxWidth = 1280;
if (sourceWidth <= maxWidth)
{
return (sourceWidth, sourceHeight);
}
var scale = maxWidth / (double)sourceWidth;
return (maxWidth, Math.Max(1, (int)Math.Round(sourceHeight * scale, MidpointRounding.AwayFromZero)));
}
private static Bitmap CaptureWindowBitmap(IntPtr handle, Rect bounds)
{
var width = Math.Max(1, bounds.Width);
@@ -1657,7 +1904,7 @@ internal static class LiveCutValidation
var summaryPath = Path.Combine(options.OutputPath, "summary.md");
var csv = new StringBuilder();
csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Phase,Success,VisualChanged,OutputVisibleInPgm,CaptureAPath,CaptureBPath,HashA,HashB,Detail");
csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Phase,CaptureMode,CaptureComparable,Success,VisualChanged,OutputVisibleInPgm,CaptureAPath,CaptureBPath,HashA,HashB,Detail");
foreach (var result in results)
{
csv.AppendLine(string.Join(",",
@@ -1667,6 +1914,8 @@ internal static class LiveCutValidation
Csv(result.CutName),
Csv(result.Channel),
Csv(result.Phase),
Csv(result.CaptureMode),
Csv(result.CaptureComparable.ToString()),
Csv(result.Success.ToString()),
Csv(result.VisualChanged.ToString()),
Csv(result.OutputVisibleInPgm.ToString()),
@@ -1682,7 +1931,7 @@ internal static class LiveCutValidation
var successCount = results.Count(result => result.Success);
var changedCount = results.Count(result => result.Success && result.VisualChanged);
var unchanged = results.Where(result => result.Success && result.OutputVisibleInPgm && !result.VisualChanged).ToList();
var unchanged = results.Where(result => result.Success && result.CaptureComparable && !result.VisualChanged).ToList();
var failures = results.Where(result => !result.Success).ToList();
var summary = new StringBuilder();
@@ -1691,6 +1940,7 @@ internal static class LiveCutValidation
summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
summary.AppendLine($"- Image Root: {options.ImageRootPath}");
summary.AppendLine($"- Output: {options.OutputPath}");
summary.AppendLine($"- Capture Mode: {options.CaptureMode}");
summary.AppendLine($"- Success: {successCount}/{results.Count}");
summary.AppendLine($"- Visual Changed: {changedCount}");
summary.AppendLine($"- Unchanged Captures: {unchanged.Count}");
@@ -1820,6 +2070,9 @@ internal static class LiveCutValidation
public int BetweenDelayMs { get; init; } = 250;
public bool IncludeVideoWall { get; init; }
public bool SwapTopTwoCandidates { get; init; }
public LiveCutCaptureMode CaptureMode { get; init; } = LiveCutCaptureMode.Pgm;
public bool CycleTopThreeCandidates { get; init; }
public bool StressTopRankValues { get; init; }
public static LiveCutValidationOptions Parse(string[] args)
{
@@ -1836,7 +2089,7 @@ internal static class LiveCutValidation
switch (args[index])
{
case "--image-root":
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
_ = RequireValue(args, ref index, "--image-root");
break;
case "--output":
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
@@ -1859,6 +2112,15 @@ internal static class LiveCutValidation
case "--swap-top-two":
options = options with { SwapTopTwoCandidates = true };
break;
case "--cycle-top-three":
options = options with { CycleTopThreeCandidates = true };
break;
case "--stress-top-ranks":
options = options with { StressTopRankValues = true };
break;
case "--capture-mode":
options = options with { CaptureMode = ParseCaptureMode(RequireValue(args, ref index, "--capture-mode")) };
break;
default:
throw new ArgumentException($"Unknown option: {args[index]}");
}
@@ -1866,7 +2128,7 @@ internal static class LiveCutValidation
return options with
{
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
OutputPath = Path.GetFullPath(options.OutputPath),
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
};
@@ -1882,6 +2144,16 @@ internal static class LiveCutValidation
return args[index];
}
private static LiveCutCaptureMode ParseCaptureMode(string raw)
{
return raw.Trim().ToLowerInvariant() switch
{
"pgm" or "window" => LiveCutCaptureMode.Pgm,
"scene" or "save-scene-image" => LiveCutCaptureMode.Scene,
_ => throw new ArgumentException($"Unknown capture mode: {raw}")
};
}
}
private sealed record CutDebugCoverageOptions
@@ -1908,7 +2180,7 @@ internal static class LiveCutValidation
switch (args[index])
{
case "--image-root":
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
_ = RequireValue(args, ref index, "--image-root");
break;
case "--output":
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
@@ -1935,7 +2207,7 @@ internal static class LiveCutValidation
return options with
{
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
OutputPath = Path.GetFullPath(options.OutputPath)
};
}
@@ -1985,7 +2257,7 @@ internal static class LiveCutValidation
switch (args[index])
{
case "--image-root":
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
_ = RequireValue(args, ref index, "--image-root");
break;
case "--output":
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
@@ -2036,7 +2308,7 @@ internal static class LiveCutValidation
return options with
{
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
OutputPath = Path.GetFullPath(options.OutputPath),
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
};
@@ -2087,6 +2359,12 @@ internal static class LiveCutValidation
Replace
}
private enum LiveCutCaptureMode
{
Pgm,
Scene
}
private readonly record struct CutDebugReplacementAssets(string MagentaPath, string CyanPath);
private sealed class CutDebugSweepResult
@@ -2163,6 +2441,8 @@ internal static class LiveCutValidation
public string CutName { get; init; } = string.Empty;
public string Channel { get; init; } = string.Empty;
public string Phase { get; init; } = string.Empty;
public string CaptureMode { get; set; } = string.Empty;
public bool CaptureComparable { get; set; }
public bool Success { get; set; }
public bool VisualChanged { get; set; }
public bool OutputVisibleInPgm { get; set; }

View File

@@ -5,6 +5,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using KAsyncEngineLib;
using Tornado3_2026Election.Services;
if (args.Length > 0 && string.Equals(args[0], "--reflect-api", StringComparison.OrdinalIgnoreCase))
{
@@ -737,6 +738,70 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
return;
}
if (options.CloneObject is not null)
{
Console.WriteLine(
$"[SAVE-IMAGE] Adding clone source={options.CloneObject.SourceObjectName} " +
$"variable={options.CloneObject.VariableName}...");
var sceneObject = scene.GetObject(options.CloneObject.SourceObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.CloneObject.SourceObjectName}' was not found."));
return;
}
handler.ResetAddCloneObjectTask();
scene.AddCloneObject(sceneObject, options.CloneObject.VariableName);
if (!WaitForTaskWithMessagePump(handler.AddCloneObjectTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnAddCloneObject timed out for '{options.CloneObject.SourceObjectName}'." ));
return;
}
var cloneResult = handler.AddCloneObjectTask.Result;
if (cloneResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", cloneResult.ToString(), options.OutputPath, $"OnAddCloneObject result={cloneResult} source={options.CloneObject.SourceObjectName} variable={options.CloneObject.VariableName}"));
return;
}
}
if (options.VariableName is not null)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting variable name object={options.VariableName.ObjectName} " +
$"value={options.VariableName.VariableName}...");
var sceneObject = scene.GetObject(options.VariableName.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.VariableName.ObjectName}' was not found."));
return;
}
handler.ResetVariableNameTask();
sceneObject.SetVariableName(options.VariableName.VariableName);
if (!WaitForTaskWithMessagePump(handler.VariableNameTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetVariableName timed out for '{options.VariableName.ObjectName}'." ));
return;
}
var variableNameResult = handler.VariableNameTask.Result;
if (variableNameResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", variableNameResult.ToString(), options.OutputPath, $"OnSetVariableName result={variableNameResult} object={options.VariableName.ObjectName}"));
return;
}
}
if (!string.IsNullOrWhiteSpace(options.SetObjectName))
{
Console.WriteLine($"[SAVE-IMAGE] Setting value object={options.SetObjectName}...");
@@ -797,6 +862,46 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
if (options.MaterialOpacity is not null)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting material opacity object={options.MaterialOpacity.ObjectName} " +
$"value={options.MaterialOpacity.Opacity}...");
var sceneObject = scene.GetObject(options.MaterialOpacity.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.MaterialOpacity.ObjectName}' was not found."));
return;
}
var material = sceneObject.GetTargetMaterial(eKMaterialTarget.MATERIAL_TARGET_DEFAULT);
if (material is not IKAMaterial targetMaterial)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.MaterialOpacity.ObjectName}' did not return a material."));
return;
}
handler.ResetMaterialOpacityTask();
targetMaterial.SetTransparencyOpacity(options.MaterialOpacity.Opacity);
if (!WaitForTaskWithMessagePump(handler.MaterialOpacityTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetTransparencyOpacity timed out for '{options.MaterialOpacity.ObjectName}'." ));
return;
}
var opacityResult = handler.MaterialOpacityTask.Result;
if (opacityResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", opacityResult.ToString(), options.OutputPath, $"OnSetTransparencyOpacity result={opacityResult} object={options.MaterialOpacity.ObjectName}"));
return;
}
}
if (options.Size is not null)
{
Console.WriteLine(
@@ -828,30 +933,37 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
var positionUpdates = new List<PositionUpdate>();
if (options.Position is not null)
{
positionUpdates.Add(options.Position);
}
positionUpdates.AddRange(options.Positions);
foreach (var positionUpdate in positionUpdates)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting position object={options.Position.ObjectName} " +
$"value=({options.Position.X},{options.Position.Y},{options.Position.Z}) vector={options.Position.VectorType}...");
var sceneObject = scene.GetObject(options.Position.ObjectName);
$"[SAVE-IMAGE] Setting position object={positionUpdate.ObjectName} " +
$"value=({positionUpdate.X},{positionUpdate.Y},{positionUpdate.Z}) vector={positionUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.Position.ObjectName}' was not found."));
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionUpdate.ObjectName}' was not found."));
return;
}
handler.ResetPositionTask();
sceneObject.SetPosition(
options.Position.X,
options.Position.Y,
options.Position.Z,
options.Position.VectorType);
positionUpdate.X,
positionUpdate.Y,
positionUpdate.Z,
positionUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{options.Position.ObjectName}'." ));
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{positionUpdate.ObjectName}'." ));
return;
}
@@ -859,7 +971,7 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
if (positionResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={options.Position.ObjectName}"));
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={positionUpdate.ObjectName}"));
return;
}
}
@@ -3279,8 +3391,12 @@ internal sealed record SaveSceneImageOptions(
string? SetObjectValue,
string? VisibleObjectName,
bool? VisibleObjectValue,
VariableNameUpdate? VariableName,
CloneObjectUpdate? CloneObject,
MaterialOpacityUpdate? MaterialOpacity,
SizeUpdate? Size,
PositionUpdate? Position,
IReadOnlyList<PositionUpdate> Positions,
PositionKeyUpdate? PositionKey,
string? ChartObjectName,
string? ChartCsvPath,
@@ -3299,10 +3415,17 @@ internal sealed record SaveSceneImageOptions(
string? setObjectValue = null;
string? visibleObjectName = null;
bool? visibleObjectValue = null;
string? variableNameObjectName = null;
string? variableNameValue = null;
string? cloneSourceObjectName = null;
string? cloneVariableName = null;
string? materialOpacityObjectName = null;
float? materialOpacityValue = null;
string? sizeObjectName = null;
string? sizeRaw = null;
string? positionObjectName = null;
string? positionRaw = null;
string? positionsRaw = null;
string? positionKeyObjectName = null;
int positionKeyIndex = 1;
string? positionKeyRaw = null;
@@ -3346,6 +3469,25 @@ internal sealed record SaveSceneImageOptions(
_ => throw new ArgumentException("--visible must be true/false/1/0.")
};
break;
case "--variable-name-object" when index + 1 < args.Length:
variableNameObjectName = args[++index];
break;
case "--variable-name" when index + 1 < args.Length:
variableNameValue = args[++index];
break;
case "--clone-source" when index + 1 < args.Length:
cloneSourceObjectName = args[++index];
break;
case "--clone-name" when index + 1 < args.Length:
cloneVariableName = args[++index];
break;
case "--material-opacity-object" when index + 1 < args.Length:
materialOpacityObjectName = args[++index];
break;
case "--material-opacity" when index + 1 < args.Length && float.TryParse(args[index + 1], out var parsedMaterialOpacity):
materialOpacityValue = parsedMaterialOpacity;
index++;
break;
case "--size-object" when index + 1 < args.Length:
sizeObjectName = args[++index];
break;
@@ -3358,6 +3500,9 @@ internal sealed record SaveSceneImageOptions(
case "--position" when index + 1 < args.Length:
positionRaw = args[++index];
break;
case "--positions" when index + 1 < args.Length:
positionsRaw = args[++index];
break;
case "--position-key-object" when index + 1 < args.Length:
positionKeyObjectName = args[++index];
break;
@@ -3426,8 +3571,12 @@ internal sealed record SaveSceneImageOptions(
setObjectValue,
visibleObjectName,
visibleObjectValue,
ParseVariableName(variableNameObjectName, variableNameValue),
ParseCloneObject(cloneSourceObjectName, cloneVariableName),
ParseMaterialOpacity(materialOpacityObjectName, materialOpacityValue),
ParseSize(sizeObjectName, sizeRaw),
ParsePosition(positionObjectName, positionRaw),
ParsePositions(positionsRaw),
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
chartObjectName,
chartCsvPath,
@@ -3437,6 +3586,36 @@ internal sealed record SaveSceneImageOptions(
ParsePathModifications(modifyPathRaw));
}
private static CloneObjectUpdate? ParseCloneObject(string? sourceObjectName, string? variableName)
{
if (string.IsNullOrWhiteSpace(sourceObjectName) || string.IsNullOrWhiteSpace(variableName))
{
return null;
}
return new CloneObjectUpdate(sourceObjectName, variableName);
}
private static MaterialOpacityUpdate? ParseMaterialOpacity(string? objectName, float? opacity)
{
if (string.IsNullOrWhiteSpace(objectName) || !opacity.HasValue)
{
return null;
}
return new MaterialOpacityUpdate(objectName, opacity.Value);
}
private static VariableNameUpdate? ParseVariableName(string? objectName, string? variableName)
{
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(variableName))
{
return null;
}
return new VariableNameUpdate(objectName, variableName);
}
private static SizeUpdate? ParseSize(string? objectName, string? raw)
{
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
@@ -3487,6 +3666,32 @@ internal sealed record SaveSceneImageOptions(
return new PositionUpdate(objectName, x, y, z, vectorType);
}
private static IReadOnlyList<PositionUpdate> ParsePositions(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return Array.Empty<PositionUpdate>();
}
var updates = new List<PositionUpdate>();
foreach (var token in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var nameParts = token.Split('=', 2, StringSplitOptions.TrimEntries);
if (nameParts.Length != 2)
{
throw new ArgumentException($"Invalid position update: {token}");
}
var update = ParsePosition(nameParts[0], nameParts[1]);
if (update is not null)
{
updates.Add(update);
}
}
return updates;
}
private static PositionKeyUpdate? ParsePositionKey(string? objectName, int keyIndex, string? raw)
{
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
@@ -3642,7 +3847,7 @@ internal sealed record SceneCatalogOptions(
public static SceneCatalogOptions Parse(string[] args)
{
var connection = ProbeOptions.Parse(args);
string? rootPath = null;
var rootPath = TornadoPathResolver.GetDefaultT3CutPath();
string? outputPath = null;
string? sceneFilter = null;
int? maxScenes = null;
@@ -3652,7 +3857,7 @@ internal sealed record SceneCatalogOptions(
switch (args[index])
{
case "--root" when index + 1 < args.Length:
rootPath = args[++index];
index++;
break;
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
@@ -3667,12 +3872,6 @@ internal sealed record SceneCatalogOptions(
}
}
rootPath ??= Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"Tornado3 Data",
"T3_Cut",
"T3_Cut");
if (!Directory.Exists(rootPath))
{
throw new DirectoryNotFoundException($"Catalog root path does not exist: {rootPath}");
@@ -3986,7 +4185,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
public static FolderInspectionOptions Parse(string[] args)
{
var connection = ProbeOptions.Parse(args);
string? rootPath = null;
var rootPath = TornadoPathResolver.GetDefaultT3CutPath();
string? outputPath = null;
string? sceneFilter = null;
int? maxScenes = null;
@@ -3996,7 +4195,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
switch (args[index])
{
case "--root" when index + 1 < args.Length:
rootPath = args[++index];
index++;
break;
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
@@ -4011,11 +4210,6 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
}
}
if (string.IsNullOrWhiteSpace(rootPath))
{
throw new ArgumentException("--root is required.");
}
rootPath = Path.GetFullPath(rootPath);
outputPath ??= Path.Combine(Environment.CurrentDirectory, "TSCN_VARIABLE_DISCOVERY.md");
outputPath = Path.GetFullPath(outputPath);
@@ -4029,6 +4223,9 @@ internal sealed record ChartCellSnapshot(int Row, int Column, string Value);
internal sealed record ChartCellUpdate(int Row, int Column, float Value);
internal sealed record PathPoint3(float X, float Y, float Z);
internal sealed record SizeUpdate(string ObjectName, float Width, float Height);
internal sealed record VariableNameUpdate(string ObjectName, string VariableName);
internal sealed record CloneObjectUpdate(string SourceObjectName, string VariableName);
internal sealed record MaterialOpacityUpdate(string ObjectName, float Opacity);
internal sealed record PositionUpdate(string ObjectName, float X, float Y, float Z, eKVectorType VectorType);
internal sealed record PositionKeyUpdate(string ObjectName, int KeyIndex, float X, float Y, float Z, eKVectorType VectorType);
internal sealed record PathPointModification(int Index, float X, float Y, float Z, eKVectorType VectorType);
@@ -4176,6 +4373,9 @@ internal sealed class ProbeEventHandler : KAEventHandler
private TaskCompletionSource<eKResult> _counterNumberKeyTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _styleColorTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _visibleTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _variableNameTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _addCloneObjectTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _materialOpacityTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _setValueTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _sizeTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _saveSceneImageTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -4203,6 +4403,12 @@ internal sealed class ProbeEventHandler : KAEventHandler
public Task<eKResult> VisibleTask => _visibleTask.Task;
public Task<eKResult> VariableNameTask => _variableNameTask.Task;
public Task<eKResult> AddCloneObjectTask => _addCloneObjectTask.Task;
public Task<eKResult> MaterialOpacityTask => _materialOpacityTask.Task;
public Task<eKResult> SetValueTask => _setValueTask.Task;
public Task<eKResult> SizeTask => _sizeTask.Task;
@@ -4235,6 +4441,12 @@ internal sealed class ProbeEventHandler : KAEventHandler
public void ResetVisibleTask() => _visibleTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetVariableNameTask() => _variableNameTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetAddCloneObjectTask() => _addCloneObjectTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetMaterialOpacityTask() => _materialOpacityTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetSetValueTask() => _setValueTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetSizeTask() => _sizeTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -4363,7 +4575,6 @@ internal sealed class ProbeEventHandler : KAEventHandler
public void OnResetDuration(eKResult Result, string SceneName) { }
public void OnSetDuration(eKResult Result, string SceneName) { }
public void OnAddObject(eKResult Result, string SceneName) { }
public void OnAddCloneObject(eKResult Result, string SceneName) { }
public void OnUpdateThumbnail(eKResult Result, string SceneName) { }
public void OnExportVideo(eKResult Result, string SceneName) { }
public void OnStopVideoExporting(eKResult Result) { }
@@ -4462,6 +4673,21 @@ internal sealed class ProbeEventHandler : KAEventHandler
_visibleTask.TrySetResult(Result);
}
public void OnSetVariableName(eKResult Result, string SceneName, string ObjectName)
{
Console.WriteLine($"[SDK] OnSetVariableName result={Result} scene={SceneName} object={ObjectName}");
_variableNameTask.TrySetResult(Result);
}
public void OnAddCloneObject(eKResult Result, string SceneName)
{
Console.WriteLine($"[SDK] OnAddCloneObject result={Result} scene={SceneName}");
_addCloneObjectTask.TrySetResult(Result);
}
public void OnSetTransparencyOpacity(eKResult Result, string SceneName, string ObjectName)
{
Console.WriteLine($"[SDK] OnSetTransparencyOpacity result={Result} scene={SceneName} object={ObjectName}");
_materialOpacityTask.TrySetResult(Result);
}
public void OnSetValue(eKResult Result, string SceneName, string ObjectName)
{
if (Result != eKResult.RESULT_ERROR_NO_VARIABLE_OBJECT)
@@ -4573,7 +4799,6 @@ internal sealed class ProbeEventHandler : KAEventHandler
public void OnAddScrollObject(eKResult Result, string SceneName, string ObjectName) { }
public void OnAdjustScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetVariableName(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetLoftPositionKey(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetChangeOut(eKResult Result, string SceneName) { }
public void OnModifyPathPoint(eKResult Result, string SceneName, string ObjectName)
@@ -4596,7 +4821,6 @@ internal sealed class ProbeEventHandler : KAEventHandler
public void OnSetColorKey(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetEmissiveColor(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetEmissiveColorKey(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetTransparencyOpacity(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetTransparencyOpacityKey(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetExposure(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetExposureKey(eKResult Result, string SceneName, string ObjectName) { }