diff --git a/RGB_COLOR_AUDIT_2026-04-29.md b/RGB_COLOR_AUDIT_2026-04-29.md
new file mode 100644
index 0000000..b14e474
--- /dev/null
+++ b/RGB_COLOR_AUDIT_2026-04-29.md
@@ -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 정당 색상 이슈 대상에서는 제외 가능함
diff --git a/SYSTEM_SPEC.md b/SYSTEM_SPEC.md
index 1511d2f..885e3e1 100644
--- a/SYSTEM_SPEC.md
+++ b/SYSTEM_SPEC.md
@@ -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 폴백
diff --git a/Tornado3_2026Election/Domain/AppPage.cs b/Tornado3_2026Election/Domain/AppPage.cs
index 116603a..fa9ae7a 100644
--- a/Tornado3_2026Election/Domain/AppPage.cs
+++ b/Tornado3_2026Election/Domain/AppPage.cs
@@ -8,6 +8,8 @@ public enum AppPage
Bottom,
VideoWall,
PreElectionData,
+ TurnoutData,
+ CountingData,
Data,
CutList,
Settings,
diff --git a/Tornado3_2026Election/Domain/FormatCutDefinition.cs b/Tornado3_2026Election/Domain/FormatCutDefinition.cs
index 75d979f..6f6fff0 100644
--- a/Tornado3_2026Election/Domain/FormatCutDefinition.cs
+++ b/Tornado3_2026Election/Domain/FormatCutDefinition.cs
@@ -7,4 +7,6 @@ public sealed class FormatCutDefinition
public required double DurationSeconds { get; set; }
public int CandidateStartIndex { get; init; }
+
+ public bool UseEndScene { get; init; }
}
diff --git a/Tornado3_2026Election/MainWindow.xaml b/Tornado3_2026Election/MainWindow.xaml
index b6adac1..bbb6204 100644
--- a/Tornado3_2026Election/MainWindow.xaml
+++ b/Tornado3_2026Election/MainWindow.xaml
@@ -49,7 +49,8 @@
-
+
+
@@ -101,7 +102,7 @@
@@ -893,7 +894,7 @@
@@ -917,13 +918,13 @@
-
+
-
+
-
@@ -960,7 +956,7 @@
"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",
diff --git a/Tornado3_2026Election/Services/ChannelScheduleEngine.cs b/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
index 473946d..8153518 100644
--- a/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
+++ b/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
@@ -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(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(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 ResolvePlaybackCuts(
FormatTemplateDefinition template,
IReadOnlyList 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(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 ApplyEndSceneToLastCut(
+ IReadOnlyList 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)
diff --git a/Tornado3_2026Election/Services/CutAppearancePolicyCatalog.cs b/Tornado3_2026Election/Services/CutAppearancePolicyCatalog.cs
index bacd7cb..5b9ae7f 100644
--- a/Tornado3_2026Election/Services/CutAppearancePolicyCatalog.cs
+++ b/Tornado3_2026Election/Services/CutAppearancePolicyCatalog.cs
@@ -6,17 +6,7 @@ namespace Tornado3_2026Election.Services;
internal static class CutAppearancePolicyCatalog
{
private static readonly IReadOnlyDictionary> DefaultAppearanceSectionsByTemplate =
- new Dictionary>(StringComparer.Ordinal)
- {
- ["1-2위_ani_광역단체장"] = CreateSectionSet(
- "정당판",
- "정당바",
- "득표수바",
- "정당원",
- "정당색",
- "정당명",
- "득표율")
- };
+ new Dictionary>(StringComparer.Ordinal);
public static bool UsesTemplateDefaultAppearance(string templateName, string sectionName)
{
diff --git a/Tornado3_2026Election/Services/FormatCatalogService.cs b/Tornado3_2026Election/Services/FormatCatalogService.cs
index 9e07dbf..a21a259 100644
--- a/Tornado3_2026Election/Services/FormatCatalogService.cs
+++ b/Tornado3_2026Election/Services/FormatCatalogService.cs
@@ -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();
}
}
diff --git a/Tornado3_2026Election/Services/KarismaChartCellUpdate.cs b/Tornado3_2026Election/Services/KarismaChartCellUpdate.cs
new file mode 100644
index 0000000..b23ba01
--- /dev/null
+++ b/Tornado3_2026Election/Services/KarismaChartCellUpdate.cs
@@ -0,0 +1,7 @@
+namespace Tornado3_2026Election.Services;
+
+public readonly record struct KarismaChartCellUpdate(
+ string ObjectName,
+ int Row,
+ int Column,
+ float Value);
diff --git a/Tornado3_2026Election/Services/KarismaPositionUpdate.cs b/Tornado3_2026Election/Services/KarismaPositionUpdate.cs
new file mode 100644
index 0000000..aabf92a
--- /dev/null
+++ b/Tornado3_2026Election/Services/KarismaPositionUpdate.cs
@@ -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);
diff --git a/Tornado3_2026Election/Services/KarismaSceneResolver.cs b/Tornado3_2026Election/Services/KarismaSceneResolver.cs
index 919a9b0..bc49ef0 100644
--- a/Tornado3_2026Election/Services/KarismaSceneResolver.cs
+++ b/Tornado3_2026Election/Services/KarismaSceneResolver.cs
@@ -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);
diff --git a/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs b/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
index 02b0b61..5ffc074 100644
--- a/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
+++ b/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
@@ -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 FindDiscoveryReportPaths()
diff --git a/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs b/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs
index e62bea7..5df6366 100644
--- a/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs
+++ b/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs
@@ -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))
{
diff --git a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
index 040176f..ea56688 100644
--- a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
+++ b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
@@ -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 FullRegionNames =
@@ -66,6 +81,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private readonly IReadOnlyDictionary _bindings;
private readonly string _connectionTarget;
private readonly Dictionary _pendingScenes = new();
+ private readonly Dictionary _pendingEndScenes = new();
private readonly Dictionary _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 values,
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ string t3CutPath,
+ string templateFolderPath,
+ IReadOnlyDictionary 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 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 values,
+ IReadOnlyDictionary 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 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();
}
@@ -812,6 +1126,69 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates;
}
+ private static bool HasVoteRateCounterVariables(IReadOnlyDictionary sceneVariables)
+ {
+ return sceneVariables.Values.Any(variable =>
+ variable.Kind == KarismaSceneVariableKind.Counter &&
+ IsVoteRateVariable(variable.Name));
+ }
+
+ private static IReadOnlyList BuildCouncilSeatCounterNumberKeyUpdates(
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var seatRows = BuildCouncilSeatSummaries(snapshot);
+ var slotCount = ResolveCouncilSeatSlotCount(sceneVariables);
+ if (slotCount <= 0)
+ {
+ return Array.Empty();
+ }
+
+ var updates = new List(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 updates,
+ IReadOnlyDictionary 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 BuildTurnoutCounterNumberKeyUpdates(
ElectionDataSnapshot snapshot,
IReadOnlyDictionary sceneVariables)
@@ -857,6 +1234,98 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates;
}
+ private static IReadOnlyList BuildChartCellUpdates(
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ if (!IsHistoricalTurnoutTemplate(template.Name))
+ {
+ return Array.Empty();
+ }
+
+ if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(HistoricalTurnoutChartObjectName))
+ {
+ return Array.Empty();
+ }
+
+ var rates = ResolveHistoricalTurnoutSlotRates(snapshot);
+ var updates = new List(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 BuildPositionUpdates(
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ if (!IsHistoricalTurnoutTemplate(template.Name))
+ {
+ return Array.Empty();
+ }
+
+ var rates = ResolveHistoricalTurnoutSlotRates(snapshot);
+ var updates = new List(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 BuildHistoricalTurnoutCounterNumberKeyUpdates(
ElectionDataSnapshot snapshot,
IReadOnlyDictionary 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);
}
diff --git a/Tornado3_2026Election/Services/PartyColorCatalog.cs b/Tornado3_2026Election/Services/PartyColorCatalog.cs
index 9c1db5b..cc86407 100644
--- a/Tornado3_2026Election/Services/PartyColorCatalog.cs
+++ b/Tornado3_2026Election/Services/PartyColorCatalog.cs
@@ -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 "더불어민주당";
}
diff --git a/Tornado3_2026Election/Services/PreElectionHistoryService.cs b/Tornado3_2026Election/Services/PreElectionHistoryService.cs
index e0168f4..c06bdbc 100644
--- a/Tornado3_2026Election/Services/PreElectionHistoryService.cs
+++ b/Tornado3_2026Election/Services/PreElectionHistoryService.cs
@@ -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;
}
diff --git a/Tornado3_2026Election/Services/SbsElectionApiClient.cs b/Tornado3_2026Election/Services/SbsElectionApiClient.cs
index 63cc9f7..f64a308 100644
--- a/Tornado3_2026Election/Services/SbsElectionApiClient.cs
+++ b/Tornado3_2026Election/Services/SbsElectionApiClient.cs
@@ -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 ElectionConfigurations =
new Dictionary(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 FullRegionNames =
@@ -50,14 +57,60 @@ public sealed class SbsElectionApiClient : IDisposable
["제주"] = "제주특별자치도"
};
+ private static readonly IReadOnlyDictionary BasicApiSidoCodes =
+ new Dictionary(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? _sidoRegions;
- private readonly Dictionary> _districtRegions = new();
+ private readonly Dictionary> _districtRegions = new(StringComparer.Ordinal);
+ private readonly Dictionary _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> GetDistrictOptionsAsync(
string electionType,
CancellationToken cancellationToken)
+ {
+ return await GetDistrictOptionsAsync(
+ electionType,
+ Array.Empty(),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task> GetDistrictOptionsAsync(
+ string electionType,
+ IEnumerable regionFilters,
+ CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
{
return Array.Empty();
}
- 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(
- $"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> GetCountingSnapshotsAsync(
+ string electionType,
+ IReadOnlyList districts,
+ CancellationToken cancellationToken)
+ {
+ if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || districts.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ 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();
+ }
+
+ 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 GetTurnoutOverviewAsync(
string electionType,
IReadOnlyList 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(
+ 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(
+ 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(
- $"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 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> GetCountingItemsForDistrictsAsync(
+ SbsElectionConfiguration configuration,
+ IReadOnlyList districts,
+ CancellationToken cancellationToken)
+ {
+ if (CanQueryCountingBySido(configuration, districts))
+ {
+ return await GetCountingItemsBySidoAsync(configuration, districts, cancellationToken).ConfigureAwait(false);
+ }
+
+ var results = new List();
+ 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> GetCountingItemsBySidoAsync(
+ SbsElectionConfiguration configuration,
+ IReadOnlyList 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> GetCountingItemsBySidoCodesAsync(
+ SbsElectionConfiguration configuration,
+ IReadOnlyList sidoCodes,
+ CancellationToken cancellationToken)
+ {
+ var results = new List();
+
+ 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> GetCountingItemsForPathAsync(
+ SbsElectionConfiguration configuration,
+ string path,
+ CancellationToken cancellationToken)
+ {
+ if (!ShouldCacheBasicCouncilCounting(configuration))
+ {
+ var uncachedItems = await GetArrayAsync(
+ 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(
+ 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 ResolveSidoRegionAsync(
+ SbsElectionConfiguration configuration,
string districtName,
string districtCode,
CancellationToken cancellationToken)
{
- _sidoRegions ??= await GetValueAsync("sungerInfo/region?type=시도", cancellationToken).ConfigureAwait(false);
+ _sidoRegions ??= await GetValueAsync(
+ configuration.BaseUri,
+ "sungerInfo/region?type=시도",
+ cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(districtCode))
{
@@ -403,12 +727,15 @@ public sealed class SbsElectionApiClient : IDisposable
}
private async Task 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(),
+ cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(districtCode))
{
@@ -462,39 +789,177 @@ public sealed class SbsElectionApiClient : IDisposable
}
private async Task> GetElectionDistrictRegionsAsync(
- int sungerType,
+ SbsElectionConfiguration configuration,
+ IReadOnlyList 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(
- $"sungerInfo/region?type=선거구&sungerType={sungerType}",
- cancellationToken).ConfigureAwait(false);
- _districtRegions[sungerType] = regions;
+ if (CanDeriveDistrictsFromCounting(configuration))
+ {
+ regions = scopedSidoCodes.Count == 0
+ ? Array.Empty()
+ : await GetCountingDistrictRegionsAsync(configuration, scopedSidoCodes, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ regions = await GetValueAsync(
+ configuration.BaseUri,
+ $"sungerInfo/region?type=선거구&sungerType={configuration.SungerType}",
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ _districtRegions[cacheKey] = regions;
}
return regions;
}
- private async Task> GetValueAsync(string relativePath, CancellationToken cancellationToken)
+ private async Task> GetCountingDistrictRegionsAsync(
+ SbsElectionConfiguration configuration,
+ IReadOnlyList 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> GetValueAsync(Uri baseUri, string relativePath, CancellationToken cancellationToken)
+ {
+ var json = await GetJsonAsync(baseUri, relativePath, cancellationToken).ConfigureAwait(false);
return DeserializeList(json, relativePath, preferValueProperty: true);
}
- private async Task> GetArrayAsync(string relativePath, CancellationToken cancellationToken)
+ private async Task> GetArrayAsync(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(json, relativePath, preferValueProperty: false);
}
- private async Task GetJsonAsync(string relativePath, CancellationToken cancellationToken)
+ private async Task 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 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 ResolveBasicApiSidoCodes(IEnumerable regionNames)
+ {
+ return (regionNames ?? Array.Empty())
+ .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 DeserializeList(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? Hubojas { get; set; }
}
+ private readonly record struct SbsCountingResponseItem(
+ SbsCountingItem Item,
+ string SourcePath);
+
+ private readonly record struct SbsCountingCacheEntry(
+ DateTimeOffset ReceivedAt,
+ IReadOnlyList Items,
+ string SourcePath);
+
private sealed class SbsCountingVoteSnapshot
{
[JsonPropertyName("tupyosu")]
diff --git a/Tornado3_2026Election/Services/ScheduleTemplatePolicy.cs b/Tornado3_2026Election/Services/ScheduleTemplatePolicy.cs
new file mode 100644
index 0000000..bd8e29e
--- /dev/null
+++ b/Tornado3_2026Election/Services/ScheduleTemplatePolicy.cs
@@ -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));
+ }
+}
diff --git a/Tornado3_2026Election/Services/TornadoManager.cs b/Tornado3_2026Election/Services/TornadoManager.cs
index 036a8a3..ac625e0 100644
--- a/Tornado3_2026Election/Services/TornadoManager.cs
+++ b/Tornado3_2026Election/Services/TornadoManager.cs
@@ -21,6 +21,7 @@ public sealed class TornadoManager : IDisposable
private readonly Timer _reconnectTimer;
private readonly Dictionary _scenes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _scenePaths = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _sceneWriteTimes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _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 visibilityUpdatesBeforeValue,
IReadOnlyDictionary values,
IReadOnlyList counterNumberKeys,
+ IReadOnlyList chartCellUpdates,
+ IReadOnlyList positionUpdates,
IReadOnlyList styleColorUpdates,
IReadOnlyList 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 visibilityUpdates)
{
foreach (var visibilityUpdate in visibilityUpdates)
diff --git a/Tornado3_2026Election/Services/TornadoPathResolver.cs b/Tornado3_2026Election/Services/TornadoPathResolver.cs
index 5544389..16dfa1f 100644
--- a/Tornado3_2026Election/Services/TornadoPathResolver.cs
+++ b/Tornado3_2026Election/Services/TornadoPathResolver.cs
@@ -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 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;
}
}
diff --git a/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs b/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
index ef11a35..6d5e10e 100644
--- a/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
@@ -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 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 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? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
diff --git a/Tornado3_2026Election/ViewModels/CutListEntryViewModel.cs b/Tornado3_2026Election/ViewModels/CutListEntryViewModel.cs
index 43c1ecb..d33387e 100644
--- a/Tornado3_2026Election/ViewModels/CutListEntryViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/CutListEntryViewModel.cs
@@ -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);
diff --git a/Tornado3_2026Election/ViewModels/DataViewModel.cs b/Tornado3_2026Election/ViewModels/DataViewModel.cs
index 97b13e6..43075ea 100644
--- a/Tornado3_2026Election/ViewModels/DataViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/DataViewModel.cs
@@ -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("국회의원", "국회의원"),
new SelectionOption("광역단체장", "광역단체장"),
new SelectionOption("교육감", "교육감"),
new SelectionOption("광역의원", "광역의원"),
@@ -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
@@ -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(),
+ 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(),
+ 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 TurnoutHistory, IReadOnlyList 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(), Array.Empty());
}
@@ -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> CreateDistrictViewSelectionOptions(
IReadOnlyList 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();
+ }
}
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 CreateCouncilSeatScheduleSnapshotAsync(
+ string electionType,
+ BroadcastStationProfile station,
+ IReadOnlyList 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())
+ .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 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 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 CreateTurnoutScheduleSnapshotAsync(
string electionType,
IReadOnlyList 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 ResolveApiDistrictRegionScope(string electionType)
+ {
+ return IsBasicCouncilElectionType(electionType)
+ ? _configuredRegions
+ : Array.Empty();
+ }
+
+ 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(),
+ 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(),
+ 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(),
+ 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 configuredRegions)
+ {
+ if (configuredRegions.Count == 0)
+ {
+ return true;
+ }
+
+ return GetNormalizedRegionKeys(regionName).Any(configuredRegions.Contains);
+ }
+
+ private static IEnumerable 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);
diff --git a/Tornado3_2026Election/ViewModels/MainViewModel.cs b/Tornado3_2026Election/ViewModels/MainViewModel.cs
index d4175d5..e49f8fd 100644
--- a/Tornado3_2026Election/ViewModels/MainViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/MainViewModel.cs
@@ -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 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,
diff --git a/Tornado3_2026Election/ViewModels/PreElectionHistoryWinnerEditRowViewModel.cs b/Tornado3_2026Election/ViewModels/PreElectionHistoryWinnerEditRowViewModel.cs
index 3f55f94..4df1446 100644
--- a/Tornado3_2026Election/ViewModels/PreElectionHistoryWinnerEditRowViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/PreElectionHistoryWinnerEditRowViewModel.cs
@@ -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;
}
diff --git a/Tornado3_2026Election/ViewModels/SettingsViewModel.cs b/Tornado3_2026Election/ViewModels/SettingsViewModel.cs
index 1ff47e1..46b72d6 100644
--- a/Tornado3_2026Election/ViewModels/SettingsViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/SettingsViewModel.cs
@@ -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;
+ }
}
}
diff --git a/plugins/cut-design-debugger/skills/cut-design-debugger/SKILL.md b/plugins/cut-design-debugger/skills/cut-design-debugger/SKILL.md
index 75c29bf..43808af 100644
--- a/plugins/cut-design-debugger/skills/cut-design-debugger/SKILL.md
+++ b/plugins/cut-design-debugger/skills/cut-design-debugger/SKILL.md
@@ -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.
diff --git a/plugins/cut-design-debugger/skills/cut-design-debugger/references/repo-map.md b/plugins/cut-design-debugger/skills/cut-design-debugger/references/repo-map.md
index e345780..29101a5 100644
--- a/plugins/cut-design-debugger/skills/cut-design-debugger/references/repo-map.md
+++ b/plugins/cut-design-debugger/skills/cut-design-debugger/references/repo-map.md
@@ -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
diff --git a/plugins/cut-design-debugger/skills/cut-design-debugger/references/validation-workflow.md b/plugins/cut-design-debugger/skills/cut-design-debugger/references/validation-workflow.md
index 135c9cb..30406a2 100644
--- a/plugins/cut-design-debugger/skills/cut-design-debugger/references/validation-workflow.md
+++ b/plugins/cut-design-debugger/skills/cut-design-debugger/references/validation-workflow.md
@@ -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
```
diff --git a/plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1 b/plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1
index e8d7d66..33e7392 100644
--- a/plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1
+++ b/plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1
@@ -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"
diff --git a/tools/KarismaSceneCatalogNet48/Program.cs b/tools/KarismaSceneCatalogNet48/Program.cs
index e5e6f81..c75cd5c 100644
--- a/tools/KarismaSceneCatalogNet48/Program.cs
+++ b/tools/KarismaSceneCatalogNet48/Program.cs
@@ -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]);
diff --git a/tools/KarismaTcpProbe/CurrentApiCutDiagnostics.cs b/tools/KarismaTcpProbe/CurrentApiCutDiagnostics.cs
index 5c8f042..9315dd0 100644
--- a/tools/KarismaTcpProbe/CurrentApiCutDiagnostics.cs
+++ b/tools/KarismaTcpProbe/CurrentApiCutDiagnostics.cs
@@ -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>(StringComparer.Ordinal);
var results = new List();
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 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> GetDistrictsAsync(
SbsElectionApiClient apiClient,
IDictionary> 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(),
+ TotalExpectedVotes = 0,
+ TurnoutVotes = 0,
+ CountedVotesFromApi = null,
+ RemainingVotesFromApi = null,
+ CountedRateFromApi = null,
+ ReceivedAt = DateTimeOffset.Now,
+ HistoricalTurnoutHistory = history?.TurnoutHistory.OrderBy(entry => entry.Year).ToArray()
+ ?? Array.Empty(),
+ HistoricalWinnerHistory = history?.WinnerHistory.OrderBy(entry => entry.ElectionOrder).ToArray()
+ ?? Array.Empty()
+ };
+ }
+
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
+ };
+ }
}
}
diff --git a/tools/KarismaTcpProbe/KarismaTcpProbe.csproj b/tools/KarismaTcpProbe/KarismaTcpProbe.csproj
index c98fbaf..82cb33c 100644
--- a/tools/KarismaTcpProbe/KarismaTcpProbe.csproj
+++ b/tools/KarismaTcpProbe/KarismaTcpProbe.csproj
@@ -41,8 +41,10 @@
+
+
@@ -52,6 +54,8 @@
+
+
diff --git a/tools/KarismaTcpProbe/LiveCutValidation.cs b/tools/KarismaTcpProbe/LiveCutValidation.cs
index a905c66..137d652 100644
--- a/tools/KarismaTcpProbe/LiveCutValidation.cs
+++ b/tools/KarismaTcpProbe/LiveCutValidation.cs
@@ -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() : CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates),
+ Candidates = preElection
+ ? Array.Empty()
+ : 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 CreateCandidates(string templateName, ScenarioMetadata metadata, int variant, bool swapTopTwoCandidates)
+ private static IReadOnlyList 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(candidateNames.Length);
for (var index = 0; index < candidateNames.Length; index++)
{
@@ -1408,6 +1476,99 @@ internal static class LiveCutValidation
return candidates;
}
+ private static IReadOnlyList 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(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 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 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; }
diff --git a/tools/KarismaTcpProbe/Program.cs b/tools/KarismaTcpProbe/Program.cs
index b4a2faf..a7d5a84 100644
--- a/tools/KarismaTcpProbe/Program.cs
+++ b/tools/KarismaTcpProbe/Program.cs
@@ -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 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 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 SaveSceneImageAsync(SaveSceneImageOptions
}
}
+ var positionUpdates = new List();
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 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 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 ParsePositions(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return Array.Empty();
+ }
+
+ var updates = new List();
+ 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 _counterNumberKeyTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource _styleColorTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource _visibleTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private TaskCompletionSource _variableNameTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private TaskCompletionSource _addCloneObjectTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private TaskCompletionSource _materialOpacityTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource _setValueTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource _sizeTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource _saveSceneImageTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -4203,6 +4403,12 @@ internal sealed class ProbeEventHandler : KAEventHandler
public Task VisibleTask => _visibleTask.Task;
+ public Task VariableNameTask => _variableNameTask.Task;
+
+ public Task AddCloneObjectTask => _addCloneObjectTask.Task;
+
+ public Task MaterialOpacityTask => _materialOpacityTask.Task;
+
public Task SetValueTask => _setValueTask.Task;
public Task SizeTask => _sizeTask.Task;
@@ -4235,6 +4441,12 @@ internal sealed class ProbeEventHandler : KAEventHandler
public void ResetVisibleTask() => _visibleTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ public void ResetVariableNameTask() => _variableNameTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public void ResetAddCloneObjectTask() => _addCloneObjectTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public void ResetMaterialOpacityTask() => _materialOpacityTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
public void ResetSetValueTask() => _setValueTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetSizeTask() => _sizeTask = new TaskCompletionSource(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) { }