컷별 데이터 연계
This commit is contained in:
31
COUNTER_DEBUG.md
Normal file
31
COUNTER_DEBUG.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Counter Debug Notes
|
||||||
|
|
||||||
|
Date: 2026-04-14
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Scene: `1-2위_ani_광역단체장`
|
||||||
|
- Counter objects: `득표율01`, `득표율02`
|
||||||
|
- API: `IKACounter.SetCounterNumberKey(KeyIndex, Number)`
|
||||||
|
- Key index used for this test: `1`
|
||||||
|
|
||||||
|
## Current test values
|
||||||
|
|
||||||
|
- `득표율01` -> `30`
|
||||||
|
- `득표율02` -> `20`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tools/KarismaTcpProbe` supports `--test-counter`.
|
||||||
|
- Verified against `127.0.0.1:30001`.
|
||||||
|
- `OnSetCounterNumberKey` returned `RESULT_SUCCESS` for both `득표율01` and `득표율02`.
|
||||||
|
|
||||||
|
## Runtime Mapping Update
|
||||||
|
|
||||||
|
- `KarismaTornado3Adapter` now maps scene variables from `ElectionDataSnapshot` at send time.
|
||||||
|
- Candidate text keys such as `후보명NN`, `정당명NN`, `득표수NN`, `득표율NN`, `순위NN`, `표차NN`, `득표차NN` are populated automatically.
|
||||||
|
- Common scene keys such as `선거구명01`, `시도명01`, `개표율01`, `투표율01`, `전국투표율01` are populated automatically.
|
||||||
|
- Animated templates with `ani` in the template id or name also send `IKACounter.SetCounterNumberKey(1, voteRate)` for each `득표율NN`.
|
||||||
|
- `유확당NN` resolves to `유력.vrv`, `확정.vrv` or `확실.vrv`, and `당선.vrv`.
|
||||||
|
- `후보사진NN` falls back to `Images/Photo/sampleNEW.png` when a candidate-specific photo is not found.
|
||||||
|
- Party image keys such as `정당바NN`, `정당판NN`, `정당심볼NN`, `그룹NN` resolve from the `Images/Dang` asset folders when a matching party file exists.
|
||||||
31
LIVE_VALIDATE_1-2위_ani_광역단체장.md
Normal file
31
LIVE_VALIDATE_1-2위_ani_광역단체장.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Scene Variable Validation
|
||||||
|
|
||||||
|
- Generated: 2026-04-14 16:51:07
|
||||||
|
- Scene: `E:\김의연\지역민방\T3_Cut\Elect2026_Normal_민방\1-2위_ani_광역단체장.tscn`
|
||||||
|
- Operations: `C:\Users\MD\source\repos\Tornado3_2026Election\tools\KarismaTcpProbe\scene-ops\1-2위_ani_광역단체장_live.json`
|
||||||
|
- Success Count: 21
|
||||||
|
- Failure Count: 0
|
||||||
|
|
||||||
|
| Object | Method | Payload | Result | Detail |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 개표율01 | SetValue | 88.8 | RESULT_SUCCESS | |
|
||||||
|
| 시도명01 | SetValue | 서울특별시 | RESULT_SUCCESS | |
|
||||||
|
| 표차01 | SetValue | 25,000 | RESULT_SUCCESS | |
|
||||||
|
| 유확당01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Tag\\당선.vrv | RESULT_SUCCESS | |
|
||||||
|
| 순위01 | SetValue | 1 | RESULT_SUCCESS | |
|
||||||
|
| 정당명01 | SetValue | 더불어민주당 | RESULT_SUCCESS | |
|
||||||
|
| 후보명01 | SetValue | 김후보 | RESULT_SUCCESS | |
|
||||||
|
| 득표수01 | SetValue | 2,123,456 | RESULT_SUCCESS | |
|
||||||
|
| 정당바01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Dang\\Dang_Map\\더불어민주당.png | RESULT_SUCCESS | |
|
||||||
|
| 정당판01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Dang\\Dang_Round\\더불어민주당.png | RESULT_SUCCESS | |
|
||||||
|
| 후보사진01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Photo\\sampleNEW.png | RESULT_SUCCESS | |
|
||||||
|
| 득표율01 | SetCounterNumberKey | keyIndex=1, number=34.8 | RESULT_SUCCESS | |
|
||||||
|
| 유확당02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Tag\\유력.vrv | RESULT_SUCCESS | |
|
||||||
|
| 순위02 | SetValue | 2 | RESULT_SUCCESS | |
|
||||||
|
| 정당명02 | SetValue | 국민의힘 | RESULT_SUCCESS | |
|
||||||
|
| 후보명02 | SetValue | 이후보 | RESULT_SUCCESS | |
|
||||||
|
| 득표수02 | SetValue | 1,123,456 | RESULT_SUCCESS | |
|
||||||
|
| 정당바02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Dang\\Dang_Map\\국민의힘.png | RESULT_SUCCESS | |
|
||||||
|
| 정당판02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Dang\\Dang_Round\\국민의힘.png | RESULT_SUCCESS | |
|
||||||
|
| 후보사진02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Photo\\sampleNEW.png | RESULT_SUCCESS | |
|
||||||
|
| 득표율02 | SetCounterNumberKey | keyIndex=1, number=32 | RESULT_SUCCESS | |
|
||||||
27
SCENE_VARIABLE_VALIDATION_1-2위_ani_광역단체장.md
Normal file
27
SCENE_VARIABLE_VALIDATION_1-2위_ani_광역단체장.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Scene Variable Validation
|
||||||
|
|
||||||
|
- Generated: 2026-04-14 15:14:18
|
||||||
|
- Scene: `C:\Users\MD\Documents\Tornado3 Data\T3_Cut\T3_Cut\Elect2026_Normal_민방\1-2위_ani_광역단체장.tscn`
|
||||||
|
- Operations: `C:\Users\MD\source\repos\Tornado3_2026Election\tools\KarismaTcpProbe\scene-ops\1-2위_ani_광역단체장.json`
|
||||||
|
- Success Count: 14
|
||||||
|
- Failure Count: 3
|
||||||
|
|
||||||
|
| Object | Method | Payload | Result | Detail |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Image | SetValue | C:\\Users\\MD\\Documents\\Tornado3 Data\\T3_Cut\\T3_Cut\\Elect2026_Normal_민방\\Images\\Etc\\가이드.png | RESULT_ERROR_NO_VARIABLE_OBJECT | |
|
||||||
|
| 시도명 | SetValue | 서울특별시 | RESULT_ERROR_NO_VARIABLE_OBJECT | |
|
||||||
|
| 표차 | SetValue | 100,000표차 | RESULT_ERROR_NO_VARIABLE_OBJECT | |
|
||||||
|
| 유확당01 | SetValue | C:\\Users\\MD\\Documents\\Tornado3 Data\\T3_Cut\\T3_Cut\\Elect2026_Normal_민방\\Images\\Tag\\당선.vrv | RESULT_SUCCESS | |
|
||||||
|
| 순위01 | SetValue | 1 | RESULT_SUCCESS | |
|
||||||
|
| 정당명01 | SetValue | 더불어민주당 | RESULT_SUCCESS | |
|
||||||
|
| 후보명01 | SetValue | 김후보 | RESULT_SUCCESS | |
|
||||||
|
| 득표수01 | SetValue | 2,123,456 | RESULT_SUCCESS | |
|
||||||
|
| 후보사진01 | SetValue | C:\\Users\\MD\\Documents\\Tornado3 Data\\T3_Cut\\T3_Cut\\Elect2026_Normal_민방\\Images\\Photo\\sampleNEW.png | RESULT_SUCCESS | |
|
||||||
|
| 득표율01 | SetCounterNumberKey | keyIndex=1, number=30 | RESULT_SUCCESS | |
|
||||||
|
| 유확당02 | SetValue | C:\\Users\\MD\\Documents\\Tornado3 Data\\T3_Cut\\T3_Cut\\Elect2026_Normal_민방\\Images\\Tag\\당선.vrv | RESULT_SUCCESS | |
|
||||||
|
| 순위02 | SetValue | 2 | RESULT_SUCCESS | |
|
||||||
|
| 정당명02 | SetValue | 국민의힘 | RESULT_SUCCESS | |
|
||||||
|
| 후보명02 | SetValue | 이후보 | RESULT_SUCCESS | |
|
||||||
|
| 득표수02 | SetValue | 1,123,456 | RESULT_SUCCESS | |
|
||||||
|
| 후보사진02 | SetValue | C:\\Users\\MD\\Documents\\Tornado3 Data\\T3_Cut\\T3_Cut\\Elect2026_Normal_민방\\Images\\Photo\\sampleNEW.png | RESULT_SUCCESS | |
|
||||||
|
| 득표율02 | SetCounterNumberKey | keyIndex=1, number=20 | RESULT_SUCCESS | |
|
||||||
@@ -239,11 +239,14 @@ IDLE → READY → SENDING → ON_AIR → NEXT
|
|||||||
## 16. Karisma / Tornado3 연동 기준
|
## 16. Karisma / Tornado3 연동 기준
|
||||||
|
|
||||||
- CG 연동 라이브러리는 `Interop.KAsyncEngineLib.dll`을 사용한다.
|
- CG 연동 라이브러리는 `Interop.KAsyncEngineLib.dll`을 사용한다.
|
||||||
|
- `Interop.KAsyncEngineLib.dll`이 `AMD64` 기준이므로 앱 실행 대상도 `x64`를 기준으로 운영한다.
|
||||||
- 기본 접속 대상은 `127.0.0.1:30001`이다.
|
- 기본 접속 대상은 `127.0.0.1:30001`이다.
|
||||||
- `TORNADO_KARISMA_HOST`가 있으면 기본 호스트 대신 사용한다.
|
- `TORNADO_KARISMA_HOST`가 있으면 기본 호스트 대신 사용한다.
|
||||||
- `TORNADO_KARISMA_PORT`가 있으면 기본 포트 대신 사용한다.
|
- `TORNADO_KARISMA_PORT`가 있으면 기본 포트 대신 사용한다.
|
||||||
|
- 앱은 시작 시 공유 Karisma 어댑터 1개를 만들고 `127.0.0.1:30001` 연결을 즉시 시도한다.
|
||||||
|
- 노멀, 좌상단, 하단, 비디오월 채널은 같은 TCP 연결을 공유하고, 채널별 `output/layer` 바인딩만 다르게 사용한다.
|
||||||
- 앱 시작 시 `T3_Cut 경로`가 유효하지 않으면 실CG 대신 Mock Adapter로 폴백한다.
|
- 앱 시작 시 `T3_Cut 경로`가 유효하지 않으면 실CG 대신 Mock Adapter로 폴백한다.
|
||||||
- 현재 구현 기준으로는 시작 시 Mock으로 결정된 경우, 설정 변경 후 실CG 재연결을 위해 앱 재시작이 필요할 수 있다.
|
- 현재 구현 기준으로는 시작 시 Mock으로 결정된 경우, 설정 변경만으로 실CG 어댑터로 승격되지 않으므로 앱 재시작이 필요할 수 있다.
|
||||||
- 채널 기본 바인딩은 `노멀=0:0`, `좌상단=0:1`, `하단=0:2`, `비디오월=1:0`이다.
|
- 채널 기본 바인딩은 `노멀=0:0`, `좌상단=0:1`, `하단=0:2`, `비디오월=1:0`이다.
|
||||||
- 환경변수 `TORNADO_KARISMA_BIND_NORMAL`, `TORNADO_KARISMA_BIND_TOPLEFT`, `TORNADO_KARISMA_BIND_BOTTOM`, `TORNADO_KARISMA_BIND_VIDEOWALL`로 채널 바인딩을 덮어쓸 수 있다.
|
- 환경변수 `TORNADO_KARISMA_BIND_NORMAL`, `TORNADO_KARISMA_BIND_TOPLEFT`, `TORNADO_KARISMA_BIND_BOTTOM`, `TORNADO_KARISMA_BIND_VIDEOWALL`로 채널 바인딩을 덮어쓸 수 있다.
|
||||||
|
|
||||||
@@ -252,6 +255,8 @@ IDLE → READY → SENDING → ON_AIR → NEXT
|
|||||||
- 사용자 설정 명칭은 `이미지 루트 경로`가 아니라 `T3_Cut 경로`로 표기한다.
|
- 사용자 설정 명칭은 `이미지 루트 경로`가 아니라 `T3_Cut 경로`로 표기한다.
|
||||||
- 송출에 사용하는 컷 파일 확장자는 `.tscn`이다.
|
- 송출에 사용하는 컷 파일 확장자는 `.tscn`이다.
|
||||||
- 컷 파일은 `T3_Cut` 루트 아래의 고정된 포맷 구조를 기준으로 사용한다.
|
- 컷 파일은 `T3_Cut` 루트 아래의 고정된 포맷 구조를 기준으로 사용한다.
|
||||||
|
- 기본 `T3_Cut` 탐색 순서는 `TORNADO_T3CUT_PATH` 환경변수, `문서\Tornado3 Data\T3_Cut\T3_Cut`, `문서\Tornado3 Data\T3_Cut`, `다운로드\T3_Cut` 순서다.
|
||||||
|
- 사용자가 상위 폴더를 선택했더라도 그 아래의 `T3_Cut` 하위 폴더에서 `.tscn` 파일이 확인되면 해당 하위 폴더를 실제 송출 루트로 정규화한다.
|
||||||
- 포맷 목록은 폴더 스캔으로 동적 생성하지 않고 하드코딩된 목록으로 관리한다.
|
- 포맷 목록은 폴더 스캔으로 동적 생성하지 않고 하드코딩된 목록으로 관리한다.
|
||||||
- 같은 컷 이름에 `_loop.tscn` 파일이 있으면 반복 송출 컷으로 사용한다.
|
- 같은 컷 이름에 `_loop.tscn` 파일이 있으면 반복 송출 컷으로 사용한다.
|
||||||
- 최초 송출 시에는 기본 컷 파일을 사용한다.
|
- 최초 송출 시에는 기본 컷 파일을 사용한다.
|
||||||
@@ -318,10 +323,18 @@ IDLE → READY → SENDING → ON_AIR → NEXT
|
|||||||
- 같은 이름의 `_loop.tscn` 파일이 있으면, 이미 송출 중인 상태에서 재호출할 때 loop 컷을 우선 사용한다.
|
- 같은 이름의 `_loop.tscn` 파일이 있으면, 이미 송출 중인 상태에서 재호출할 때 loop 컷을 우선 사용한다.
|
||||||
|
|
||||||
### CG 연결 상태 표시 규칙
|
### CG 연결 상태 표시 규칙
|
||||||
- CG 상태는 Karisma 어댑터 존재 여부가 아니라 실제 TCP `30001` 연결 성공 여부를 기준으로 표시한다.
|
- CG 상태는 Karisma 어댑터 존재 여부가 아니라 공유 TCP `30001` 연결 성공 여부를 기준으로 표시한다.
|
||||||
- `Connected / Disconnected` 표시는 `OnConnect` 및 `OnClose` 콜백 기준으로 갱신한다.
|
- `Connected / Disconnected` 표시는 `OnConnect` 및 `OnClose` 콜백 기준으로 갱신한다.
|
||||||
|
- 공유 연결 상태는 해당 연결을 사용하는 모든 채널 패널에 동일하게 반영한다.
|
||||||
- TCP 연결이 끊기면 5초 간격으로 자동 재접속을 시도한다.
|
- TCP 연결이 끊기면 5초 간격으로 자동 재접속을 시도한다.
|
||||||
|
|
||||||
|
## 2026-04-14 TCP / SetValue 디버깅 업데이트
|
||||||
|
|
||||||
|
- 앱 실행 직후 `30001`과의 TCP 연결을 바로 시도하고, 이후 각 채널은 그 단일 연결을 공유한다.
|
||||||
|
- Karisma SDK 콜백 수신을 위해 전용 STA 스레드에서 메시지 펌프를 유지한다.
|
||||||
|
- `SetValue` 검증을 위해 후보 이름 키를 기존 `Candidate1Name`, `Candidate2Name` 외에 `후보명01`, `후보명02`로도 함께 전달한다.
|
||||||
|
- 현재 테스트 빌드 기준 `후보명01=김후보`, `후보명02=이후보`를 함께 송신해 실제 장면 변수 반영 여부를 확인한다.
|
||||||
|
|
||||||
### 인코딩 확인 원칙
|
### 인코딩 확인 원칙
|
||||||
- 터미널 출력이 깨져 보이는 것과 파일 자체 인코딩 손상을 구분해서 판단한다.
|
- 터미널 출력이 깨져 보이는 것과 파일 자체 인코딩 손상을 구분해서 판단한다.
|
||||||
- 한글 문자열 상태 판단은 편집기 화면 또는 `UTF-8` 파일 직접 읽기 기준으로 확인한다.
|
- 한글 문자열 상태 판단은 편집기 화면 또는 `UTF-8` 파일 직접 읽기 기준으로 확인한다.
|
||||||
|
|||||||
4883
TSCN_VARIABLE_DISCOVERY_E_DRIVE.md
Normal file
4883
TSCN_VARIABLE_DISCOVERY_E_DRIVE.md
Normal file
File diff suppressed because it is too large
Load Diff
46
TSCN_VARIABLE_DISCOVERY_ONE.md
Normal file
46
TSCN_VARIABLE_DISCOVERY_ONE.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# TSCN Variable Discovery
|
||||||
|
|
||||||
|
- Generated: 2026-04-14 15:24:09
|
||||||
|
- Root: `E:\김의연\지역민방\T3_Cut`
|
||||||
|
- Scene Count: 1
|
||||||
|
- Discovered Variable Count: 21
|
||||||
|
- Failure Count: 0
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
- Candidate names are extracted from each `.tscn` as UTF-16LE strings.
|
||||||
|
- Each candidate is verified through Karisma TCP callbacks.
|
||||||
|
- `SetValue(__TCP_VALIDATE__)`, valid `.png`, valid `.vrv`, and `SetCounterNumberKey(1, 1)` are tried as applicable.
|
||||||
|
- Only callbacks that returned `RESULT_SUCCESS` are listed as discovered variables.
|
||||||
|
|
||||||
|
## Scenes
|
||||||
|
|
||||||
|
### `Elect2026_Normal_민방\1-2위_ani_광역단체장.tscn`
|
||||||
|
|
||||||
|
- Candidate Count: 28
|
||||||
|
- Discovered Variables: 21
|
||||||
|
|
||||||
|
| Variable | Method | Payload | Result |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 개표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 득표수01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 득표수02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 득표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 득표율02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 순위01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 순위02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 시도명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 유확당01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
|
||||||
|
| 유확당02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
|
||||||
|
| 정당명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 정당명02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 정당바01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
|
||||||
|
| 정당바02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
|
||||||
|
| 정당판01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
|
||||||
|
| 정당판02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
|
||||||
|
| 표차01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 후보명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 후보명02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
|
||||||
|
| 후보사진01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
|
||||||
|
| 후보사진02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
|
||||||
|
|
||||||
38
TSCN_VARIABLE_DISCOVERY_SAMPLE.md
Normal file
38
TSCN_VARIABLE_DISCOVERY_SAMPLE.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# TSCN Variable Discovery
|
||||||
|
|
||||||
|
- Generated: 2026-04-14 15:21:19
|
||||||
|
- Root: `E:\김의연\지역민방\T3_Cut`
|
||||||
|
- Scene Count: 3
|
||||||
|
- Discovered Variable Count: 0
|
||||||
|
- Failure Count: 0
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
- Candidate names are extracted from each `.tscn` as UTF-16LE strings.
|
||||||
|
- Each candidate is verified through Karisma TCP callbacks.
|
||||||
|
- `SetValue(__TCP_VALIDATE__)`, valid `.png`, valid `.vrv`, and `SetCounterNumberKey(1, 1)` are tried as applicable.
|
||||||
|
- Only callbacks that returned `RESULT_SUCCESS` are listed as discovered variables.
|
||||||
|
|
||||||
|
## Scenes
|
||||||
|
|
||||||
|
### `Elect2026_Bottom_민방\1-2위_광역단체장.tscn`
|
||||||
|
|
||||||
|
- Candidate Count: 54
|
||||||
|
- Discovered Variables: 0
|
||||||
|
|
||||||
|
- No variables discovered with the current TCP validation heuristics.
|
||||||
|
|
||||||
|
### `Elect2026_Bottom_민방\1-2위_광역단체장_loop.tscn`
|
||||||
|
|
||||||
|
- Candidate Count: 53
|
||||||
|
- Discovered Variables: 0
|
||||||
|
|
||||||
|
- No variables discovered with the current TCP validation heuristics.
|
||||||
|
|
||||||
|
### `Elect2026_Bottom_민방\1-2위_기초단체장.tscn`
|
||||||
|
|
||||||
|
- Candidate Count: 53
|
||||||
|
- Discovered Variables: 0
|
||||||
|
|
||||||
|
- No variables discovered with the current TCP validation heuristics.
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ public sealed class CandidateEntry : ObservableObject
|
|||||||
public string EffectiveJudgementLabel => EffectiveJudgement switch
|
public string EffectiveJudgementLabel => EffectiveJudgement switch
|
||||||
{
|
{
|
||||||
CandidateJudgement.Leading => "유력",
|
CandidateJudgement.Leading => "유력",
|
||||||
CandidateJudgement.Confirmed => "확실",
|
CandidateJudgement.Confirmed => "확정",
|
||||||
CandidateJudgement.Elected => "당선",
|
CandidateJudgement.Elected => "당선",
|
||||||
_ => "-"
|
_ => "-"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Tornado3_2026Election.Services;
|
||||||
|
|
||||||
|
public readonly record struct KarismaCounterNumberKeyUpdate(string ObjectName, int KeyIndex, double Number);
|
||||||
@@ -143,7 +143,7 @@ public class KarismaEventHandler : KAEventHandler
|
|||||||
virtual public void OnQueryChartDataTable(eKResult Result, string SceneName, string ObjectName, KAChartDataTable Table) { }
|
virtual public void OnQueryChartDataTable(eKResult Result, string SceneName, string ObjectName, KAChartDataTable Table) { }
|
||||||
virtual public void OnQuerySize(eKResult Result, string SceneName, string ObjectName, float Width, float Height) { }
|
virtual public void OnQuerySize(eKResult Result, string SceneName, string ObjectName, float Width, float Height) { }
|
||||||
virtual public void OnSetSize(eKResult Result, string SceneName, string ObjectName) { }
|
virtual public void OnSetSize(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
virtual public void OnSetCounterNumberKey(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnSetCounterNumberKey(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetCounterNumberKey), Result, $"scene={SceneName} object={ObjectName}");
|
||||||
virtual public void OnSetPositionKey(eKResult Result, string SceneName, string ObjectName) { }
|
virtual public void OnSetPositionKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
virtual public void OnSetRotationKey(eKResult Result, string SceneName, string ObjectName) { }
|
virtual public void OnSetRotationKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
virtual public void OnSetScaleKey(eKResult Result, string SceneName, string ObjectName) { }
|
virtual public void OnSetScaleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
|
|||||||
229
Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
Normal file
229
Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Tornado3_2026Election.Services;
|
||||||
|
|
||||||
|
public sealed class KarismaSceneVariableCatalog
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, KarismaSceneVariableDefinition> EmptySceneVariables =
|
||||||
|
new Dictionary<string, KarismaSceneVariableDefinition>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private readonly IReadOnlyDictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>> _scenes;
|
||||||
|
|
||||||
|
private KarismaSceneVariableCatalog(
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>> scenes)
|
||||||
|
{
|
||||||
|
_scenes = scenes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static KarismaSceneVariableCatalog Load(LogService logService)
|
||||||
|
{
|
||||||
|
var reportPath = FindDiscoveryReportPath();
|
||||||
|
if (string.IsNullOrWhiteSpace(reportPath) || !File.Exists(reportPath))
|
||||||
|
{
|
||||||
|
logService.Warning("Karisma scene variable catalog report was not found. Falling back to runtime value heuristics.");
|
||||||
|
return new KarismaSceneVariableCatalog(
|
||||||
|
new Dictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scenes = ParseReport(reportPath);
|
||||||
|
logService.Info($"Karisma scene variable catalog loaded: scenes={scenes.Count} source='{reportPath}'.");
|
||||||
|
return new KarismaSceneVariableCatalog(scenes);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logService.Warning($"Failed to load Karisma scene variable catalog: {ex.Message}");
|
||||||
|
return new KarismaSceneVariableCatalog(
|
||||||
|
new Dictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, KarismaSceneVariableDefinition> GetSceneVariables(string t3CutPath, string scenePath)
|
||||||
|
{
|
||||||
|
if (_scenes.Count == 0 ||
|
||||||
|
string.IsNullOrWhiteSpace(t3CutPath) ||
|
||||||
|
string.IsNullOrWhiteSpace(scenePath))
|
||||||
|
{
|
||||||
|
return EmptySceneVariables;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath = NormalizeRelativePath(Path.GetRelativePath(t3CutPath, scenePath));
|
||||||
|
return _scenes.TryGetValue(relativePath, out var variables)
|
||||||
|
? variables
|
||||||
|
: EmptySceneVariables;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>> ParseReport(string reportPath)
|
||||||
|
{
|
||||||
|
var scenes = new Dictionary<string, Dictionary<string, KarismaSceneVariableDefinition>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
string? currentScene = null;
|
||||||
|
|
||||||
|
foreach (var rawLine in File.ReadLines(reportPath, Encoding.UTF8))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (TryParseSceneHeader(line, out var sceneRelativePath))
|
||||||
|
{
|
||||||
|
currentScene = NormalizeRelativePath(sceneRelativePath);
|
||||||
|
if (!scenes.ContainsKey(currentScene))
|
||||||
|
{
|
||||||
|
scenes[currentScene] = new Dictionary<string, KarismaSceneVariableDefinition>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(currentScene) || !line.StartsWith('|'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cells = SplitMarkdownRow(line);
|
||||||
|
if (cells.Count < 4 ||
|
||||||
|
string.Equals(cells[0], "Variable", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(cells[0], "---", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var variableName = cells[0];
|
||||||
|
if (string.IsNullOrWhiteSpace(variableName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var method = cells[1];
|
||||||
|
var payload = cells[2];
|
||||||
|
var result = cells[3];
|
||||||
|
if (!string.Equals(result, "RESULT_SUCCESS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
scenes[currentScene][variableName] = new KarismaSceneVariableDefinition(
|
||||||
|
variableName,
|
||||||
|
ResolveKind(variableName, method, payload),
|
||||||
|
method,
|
||||||
|
payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scenes.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => (IReadOnlyDictionary<string, KarismaSceneVariableDefinition>)pair.Value,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseSceneHeader(string line, out string sceneRelativePath)
|
||||||
|
{
|
||||||
|
sceneRelativePath = string.Empty;
|
||||||
|
if (!line.StartsWith("### `", StringComparison.Ordinal) || !line.EndsWith('`'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneRelativePath = line.Substring(5, line.Length - 6);
|
||||||
|
return !string.IsNullOrWhiteSpace(sceneRelativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> SplitMarkdownRow(string line)
|
||||||
|
{
|
||||||
|
var cells = line.Split('|');
|
||||||
|
if (cells.Length <= 2)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
.Skip(1)
|
||||||
|
.Take(cells.Length - 2)
|
||||||
|
.Select(cell => cell.Trim())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KarismaSceneVariableKind ResolveKind(string variableName, string method, string payload)
|
||||||
|
{
|
||||||
|
if (string.Equals(method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return KarismaSceneVariableKind.Counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variableName.StartsWith("\uC720\uD655\uB2F9", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return KarismaSceneVariableKind.VideoResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.EndsWith(".vrv", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return KarismaSceneVariableKind.VideoResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
payload.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
payload.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
payload.EndsWith(".webp", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return KarismaSceneVariableKind.Image;
|
||||||
|
}
|
||||||
|
|
||||||
|
return KarismaSceneVariableKind.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindDiscoveryReportPath()
|
||||||
|
{
|
||||||
|
foreach (var startPath in EnumerateSearchRoots())
|
||||||
|
{
|
||||||
|
var current = startPath;
|
||||||
|
for (var depth = 0; depth < 8 && !string.IsNullOrWhiteSpace(current); depth++)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(current, "TSCN_VARIABLE_DISCOVERY_E_DRIVE.md");
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = Path.GetDirectoryName(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> EnumerateSearchRoots()
|
||||||
|
{
|
||||||
|
var roots = new List<string> { AppContext.BaseDirectory };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
roots.Add(Directory.GetCurrentDirectory());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRelativePath(string relativePath)
|
||||||
|
{
|
||||||
|
return relativePath
|
||||||
|
.Replace('/', '\\')
|
||||||
|
.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record KarismaSceneVariableDefinition(
|
||||||
|
string Name,
|
||||||
|
KarismaSceneVariableKind Kind,
|
||||||
|
string Method,
|
||||||
|
string Payload);
|
||||||
|
|
||||||
|
public enum KarismaSceneVariableKind
|
||||||
|
{
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
VideoResource,
|
||||||
|
Counter
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
private readonly TornadoManager _manager;
|
private readonly TornadoManager _manager;
|
||||||
private readonly LogService _logService;
|
private readonly LogService _logService;
|
||||||
private readonly Func<string> _t3CutPathProvider;
|
private readonly Func<string> _t3CutPathProvider;
|
||||||
|
private readonly KarismaSceneVariableCatalog _sceneVariableCatalog;
|
||||||
private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings;
|
private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings;
|
||||||
private readonly string _connectionTarget;
|
private readonly string _connectionTarget;
|
||||||
private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new();
|
private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new();
|
||||||
@@ -27,12 +28,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
TornadoManager manager,
|
TornadoManager manager,
|
||||||
LogService logService,
|
LogService logService,
|
||||||
Func<string> t3CutPathProvider,
|
Func<string> t3CutPathProvider,
|
||||||
|
KarismaSceneVariableCatalog sceneVariableCatalog,
|
||||||
string connectionTarget,
|
string connectionTarget,
|
||||||
IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> bindings)
|
IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> bindings)
|
||||||
{
|
{
|
||||||
_manager = manager;
|
_manager = manager;
|
||||||
_logService = logService;
|
_logService = logService;
|
||||||
_t3CutPathProvider = t3CutPathProvider;
|
_t3CutPathProvider = t3CutPathProvider;
|
||||||
|
_sceneVariableCatalog = sceneVariableCatalog;
|
||||||
_connectionTarget = connectionTarget;
|
_connectionTarget = connectionTarget;
|
||||||
_bindings = bindings;
|
_bindings = bindings;
|
||||||
_manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty);
|
_manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty);
|
||||||
@@ -105,10 +108,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var manager = new TornadoManager(host, port, logService);
|
var manager = new TornadoManager(host, port, logService);
|
||||||
|
var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
|
||||||
adapter = new KarismaTornado3Adapter(
|
adapter = new KarismaTornado3Adapter(
|
||||||
manager,
|
manager,
|
||||||
logService,
|
logService,
|
||||||
t3CutPathProvider,
|
t3CutPathProvider,
|
||||||
|
sceneVariableCatalog,
|
||||||
$"{host}:{port}",
|
$"{host}:{port}",
|
||||||
BuildBindings());
|
BuildBindings());
|
||||||
return true;
|
return true;
|
||||||
@@ -141,12 +146,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
var binding = ResolveBinding(channel);
|
var binding = ResolveBinding(channel);
|
||||||
var t3CutPath = ResolveT3CutPath();
|
var t3CutPath = ResolveT3CutPath();
|
||||||
var resolvedScene = ResolveScene(template, t3CutPath, IsChannelOnAir(channel));
|
var resolvedScene = ResolveScene(template, t3CutPath, IsChannelOnAir(channel));
|
||||||
var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath);
|
var sceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path);
|
||||||
|
var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath, sceneVariables);
|
||||||
|
var counterNumberKeys = BuildCounterNumberKeyUpdates(template, snapshot, sceneVariables);
|
||||||
|
|
||||||
State = TornadoConnectionState.Sending;
|
State = TornadoConnectionState.Sending;
|
||||||
await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await _manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false);
|
await _manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false);
|
||||||
await _manager.ApplyValuesAsync(resolvedScene.Alias, values, cancellationToken).ConfigureAwait(false);
|
await _manager.ApplyValuesAsync(resolvedScene.Alias, values, counterNumberKeys, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
_pendingScenes[channel] = resolvedScene.Alias;
|
_pendingScenes[channel] = resolvedScene.Alias;
|
||||||
_logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}");
|
_logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}");
|
||||||
@@ -318,8 +325,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
FormatCutDefinition cut,
|
FormatCutDefinition cut,
|
||||||
ElectionDataSnapshot snapshot,
|
ElectionDataSnapshot snapshot,
|
||||||
BroadcastStationProfile station,
|
BroadcastStationProfile station,
|
||||||
string t3CutPath)
|
string t3CutPath,
|
||||||
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
{
|
{
|
||||||
|
var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
|
||||||
|
var countedRateDisplay = FormatRate(CalculateCountedRate(snapshot));
|
||||||
|
var turnoutRateDisplay = FormatRate(snapshot.TurnoutRate);
|
||||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["TemplateId"] = template.Id,
|
["TemplateId"] = template.Id,
|
||||||
@@ -348,6 +359,10 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
["Timestamp"] = snapshot.ReceivedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
["Timestamp"] = snapshot.ReceivedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
SetAliases(values, snapshot.DistrictName, "선거구명", "선거구명01", "시도명", "시도명01");
|
||||||
|
SetAliases(values, countedRateDisplay, "개표율", "개표율01");
|
||||||
|
SetAliases(values, turnoutRateDisplay, "투표율", "투표율01", "전국투표율", "전국투표율01");
|
||||||
|
|
||||||
var orderedCandidates = snapshot.Candidates
|
var orderedCandidates = snapshot.Candidates
|
||||||
.OrderByDescending(candidate => candidate.VoteCount)
|
.OrderByDescending(candidate => candidate.VoteCount)
|
||||||
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||||
@@ -357,19 +372,48 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
{
|
{
|
||||||
var candidate = orderedCandidates[index];
|
var candidate = orderedCandidates[index];
|
||||||
var slot = index + 1;
|
var slot = index + 1;
|
||||||
|
var voteCountDisplay = FormatCount(candidate.VoteCount);
|
||||||
|
var voteRateDisplay = FormatRate(candidate.VoteRate);
|
||||||
|
var voteGapDisplay = FormatCount(CalculateVoteGap(orderedCandidates, index));
|
||||||
|
var rankDisplay = slot.ToString(CultureInfo.InvariantCulture);
|
||||||
|
var rankImagePath = ResolveRankAssetPath(t3CutPath, templateFolderPath, slot);
|
||||||
|
var judgementPath = ResolveJudgementAssetPath(t3CutPath, templateFolderPath, candidate.EffectiveJudgement);
|
||||||
|
var candidateImagePath = ResolveCandidateImagePath(t3CutPath, templateFolderPath, candidate);
|
||||||
|
var partyBarPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Bar);
|
||||||
|
var partyPlatePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Plate);
|
||||||
|
var partySymbolPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Symbol);
|
||||||
|
var groupPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Group);
|
||||||
|
|
||||||
values[$"Candidate{slot}Code"] = candidate.CandidateCode;
|
values[$"Candidate{slot}Code"] = candidate.CandidateCode;
|
||||||
values[$"Candidate{slot}Name"] = candidate.Name;
|
values[$"Candidate{slot}Name"] = candidate.Name;
|
||||||
values[$"후보명{slot:00}"] = candidate.Name;
|
|
||||||
values[$"Candidate{slot}Party"] = candidate.Party;
|
values[$"Candidate{slot}Party"] = candidate.Party;
|
||||||
values[$"Candidate{slot}VoteCount"] = candidate.VoteCount.ToString(CultureInfo.InvariantCulture);
|
values[$"Candidate{slot}VoteCount"] = candidate.VoteCount.ToString(CultureInfo.InvariantCulture);
|
||||||
values[$"Candidate{slot}VoteCountDisplay"] = candidate.VoteCount.ToString("N0", CultureInfo.InvariantCulture);
|
values[$"Candidate{slot}VoteCountDisplay"] = voteCountDisplay;
|
||||||
values[$"Candidate{slot}VoteRate"] = candidate.VoteRate.ToString("0.0", CultureInfo.InvariantCulture);
|
values[$"Candidate{slot}VoteRate"] = voteRateDisplay;
|
||||||
values[$"Candidate{slot}Judgement"] = candidate.EffectiveJudgementLabel;
|
values[$"Candidate{slot}Judgement"] = candidate.EffectiveJudgementLabel;
|
||||||
values[$"Candidate{slot}ImagePath"] = ResolveCandidateImagePath(t3CutPath, candidate);
|
values[$"Candidate{slot}ImagePath"] = candidateImagePath;
|
||||||
}
|
|
||||||
|
|
||||||
values["후보명01"] = "김후보";
|
SetRankAliases(values, sceneVariables, rankDisplay, rankImagePath, $"순위{slot:00}", $"순위{slot}");
|
||||||
values["후보명02"] = "이후보";
|
SetAliases(values, candidate.Name, $"후보명{slot:00}", $"후보명{slot}");
|
||||||
|
SetAliases(values, candidate.Party, $"정당명{slot:00}", $"정당명{slot}");
|
||||||
|
SetAliases(values, voteCountDisplay, $"득표수{slot:00}", $"득표수{slot}");
|
||||||
|
SetAliases(values, voteRateDisplay, $"득표율{slot:00}", $"득표율{slot}");
|
||||||
|
SetAliases(values, voteGapDisplay, $"표차{slot:00}", $"표차{slot}", $"득표차{slot:00}", $"득표차{slot}");
|
||||||
|
SetAliases(
|
||||||
|
values,
|
||||||
|
snapshot.DistrictName,
|
||||||
|
$"선거구명{slot:00}",
|
||||||
|
$"선거구명{slot}",
|
||||||
|
$"시도명{slot:00}",
|
||||||
|
$"시도명{slot}");
|
||||||
|
SetAliases(values, countedRateDisplay, $"개표율{slot:00}", $"개표율{slot}");
|
||||||
|
SetOptionalAliases(values, judgementPath, $"유확당{slot:00}", $"유확당{slot}");
|
||||||
|
SetOptionalAliases(values, candidateImagePath, $"후보사진{slot:00}", $"후보사진{slot}");
|
||||||
|
SetOptionalAliases(values, partyBarPath, $"정당바{slot:00}", $"정당바{slot}");
|
||||||
|
SetOptionalAliases(values, partyPlatePath, $"정당판{slot:00}", $"정당판{slot}");
|
||||||
|
SetOptionalAliases(values, partySymbolPath, $"정당심볼{slot:00}", $"정당심볼{slot}");
|
||||||
|
SetOptionalAliases(values, groupPath, $"그룹{slot:00}", $"그룹{slot}");
|
||||||
|
}
|
||||||
|
|
||||||
if (orderedCandidates.FirstOrDefault() is { } leader)
|
if (orderedCandidates.FirstOrDefault() is { } leader)
|
||||||
{
|
{
|
||||||
@@ -377,34 +421,366 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
values["LeaderName"] = leader.Name;
|
values["LeaderName"] = leader.Name;
|
||||||
values["LeaderParty"] = leader.Party;
|
values["LeaderParty"] = leader.Party;
|
||||||
values["LeaderVoteCount"] = leader.VoteCount.ToString(CultureInfo.InvariantCulture);
|
values["LeaderVoteCount"] = leader.VoteCount.ToString(CultureInfo.InvariantCulture);
|
||||||
values["LeaderVoteCountDisplay"] = leader.VoteCount.ToString("N0", CultureInfo.InvariantCulture);
|
values["LeaderVoteCountDisplay"] = FormatCount(leader.VoteCount);
|
||||||
values["LeaderVoteRate"] = leader.VoteRate.ToString("0.0", CultureInfo.InvariantCulture);
|
values["LeaderVoteRate"] = FormatRate(leader.VoteRate);
|
||||||
values["LeaderJudgement"] = leader.EffectiveJudgementLabel;
|
values["LeaderJudgement"] = leader.EffectiveJudgementLabel;
|
||||||
values["LeaderImagePath"] = ResolveCandidateImagePath(t3CutPath, leader);
|
values["LeaderImagePath"] = ResolveCandidateImagePath(t3CutPath, templateFolderPath, leader);
|
||||||
}
|
}
|
||||||
|
|
||||||
return values;
|
return FilterValuesForScene(values, sceneVariables);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveCandidateImagePath(string t3CutPath, CandidateEntry candidate)
|
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildCounterNumberKeyUpdates(
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
ElectionDataSnapshot snapshot,
|
||||||
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
{
|
{
|
||||||
if (!candidate.HasImage || string.IsNullOrWhiteSpace(t3CutPath) || string.IsNullOrWhiteSpace(candidate.CandidateCode))
|
if (!IsAnimatedTemplate(template))
|
||||||
|
{
|
||||||
|
return Array.Empty<KarismaCounterNumberKeyUpdate>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderedCandidates = snapshot.Candidates
|
||||||
|
.OrderByDescending(candidate => candidate.VoteCount)
|
||||||
|
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (orderedCandidates.Length == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<KarismaCounterNumberKeyUpdate>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates = new List<KarismaCounterNumberKeyUpdate>(orderedCandidates.Length);
|
||||||
|
for (var index = 0; index < orderedCandidates.Length; index++)
|
||||||
|
{
|
||||||
|
var slot = index + 1;
|
||||||
|
var variableName = $"득표율{slot:00}";
|
||||||
|
if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.Add(new KarismaCounterNumberKeyUpdate(
|
||||||
|
variableName,
|
||||||
|
1,
|
||||||
|
Math.Round(orderedCandidates[index].VoteRate, 1, MidpointRounding.AwayFromZero)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCandidateImagePath(string t3CutPath, string templateFolderPath, CandidateEntry candidate)
|
||||||
|
{
|
||||||
|
if (!candidate.HasImage || string.IsNullOrWhiteSpace(t3CutPath))
|
||||||
{
|
{
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var extension in new[] { ".png", ".jpg", ".jpeg", ".webp" })
|
var relativePaths = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(candidate.CandidateCode))
|
||||||
{
|
{
|
||||||
var path = Path.Combine(t3CutPath, candidate.CandidateCode + extension);
|
foreach (var extension in new[] { ".png", ".jpg", ".jpeg", ".webp" })
|
||||||
if (File.Exists(path))
|
|
||||||
{
|
{
|
||||||
return path;
|
relativePaths.Add(Path.Combine("Images", "Photo", candidate.CandidateCode + extension));
|
||||||
|
relativePaths.Add(candidate.CandidateCode + extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var sampleFileName in new[] { "sampleNEW.png", "sampleNEW.jpg", "sampleNEW.jpeg", "sample.png" })
|
||||||
|
{
|
||||||
|
relativePaths.Add(Path.Combine("Images", "Photo", sampleFileName));
|
||||||
|
relativePaths.Add(sampleFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolveAssetAcrossRoots(t3CutPath, templateFolderPath, relativePaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveRankAssetPath(string t3CutPath, string templateFolderPath, int rank)
|
||||||
|
{
|
||||||
|
if (rank <= 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolveAssetAcrossRoots(
|
||||||
|
t3CutPath,
|
||||||
|
templateFolderPath,
|
||||||
|
new[] { Path.Combine("Images", "Rank", $"{rank}위.png") });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveJudgementAssetPath(string t3CutPath, string templateFolderPath, CandidateJudgement judgement)
|
||||||
|
{
|
||||||
|
string[] fileNames = judgement switch
|
||||||
|
{
|
||||||
|
CandidateJudgement.Leading => new[] { "유력.vrv" },
|
||||||
|
CandidateJudgement.Confirmed => new[] { "확정.vrv", "확실.vrv" },
|
||||||
|
CandidateJudgement.Elected => new[] { "당선.vrv" },
|
||||||
|
_ => Array.Empty<string>()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fileNames.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolveAssetAcrossRoots(
|
||||||
|
t3CutPath,
|
||||||
|
templateFolderPath,
|
||||||
|
fileNames.Select(fileName => Path.Combine("Images", "Tag", fileName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolvePartyAssetPath(
|
||||||
|
string t3CutPath,
|
||||||
|
string templateFolderPath,
|
||||||
|
string partyName,
|
||||||
|
PartyAssetKind assetKind)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(partyName))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePaths = new List<string>();
|
||||||
|
foreach (var candidateFileName in GetPartyFileNameCandidates(partyName))
|
||||||
|
{
|
||||||
|
switch (assetKind)
|
||||||
|
{
|
||||||
|
case PartyAssetKind.Bar:
|
||||||
|
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
|
||||||
|
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
|
||||||
|
break;
|
||||||
|
case PartyAssetKind.Plate:
|
||||||
|
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
|
||||||
|
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
|
||||||
|
break;
|
||||||
|
case PartyAssetKind.Symbol:
|
||||||
|
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Symbol", candidateFileName + ".png"));
|
||||||
|
break;
|
||||||
|
case PartyAssetKind.Group:
|
||||||
|
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
|
||||||
|
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
|
||||||
|
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Symbol", candidateFileName + ".png"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolveAssetAcrossRoots(t3CutPath, templateFolderPath, relativePaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveTemplateFolderPath(string t3CutPath, FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
var normalizedTemplateId = template.Id
|
||||||
|
.Replace('\\', Path.DirectorySeparatorChar)
|
||||||
|
.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
var relativeDirectory = Path.GetDirectoryName(normalizedTemplateId);
|
||||||
|
return string.IsNullOrWhiteSpace(relativeDirectory)
|
||||||
|
? t3CutPath
|
||||||
|
: Path.Combine(t3CutPath, relativeDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double CalculateCountedRate(ElectionDataSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var denominator = snapshot.TurnoutVotes > 0
|
||||||
|
? snapshot.TurnoutVotes
|
||||||
|
: snapshot.TotalExpectedVotes;
|
||||||
|
|
||||||
|
return denominator <= 0
|
||||||
|
? 0d
|
||||||
|
: Math.Round(snapshot.CountedVotes * 100d / denominator, 1, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CalculateVoteGap(IReadOnlyList<CandidateEntry> orderedCandidates, int index)
|
||||||
|
{
|
||||||
|
if (orderedCandidates.Count <= 1 || index < 0 || index >= orderedCandidates.Count)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var comparisonIndex = index < orderedCandidates.Count - 1
|
||||||
|
? index + 1
|
||||||
|
: index - 1;
|
||||||
|
return Math.Abs(orderedCandidates[index].VoteCount - orderedCandidates[comparisonIndex].VoteCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAnimatedTemplate(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return template.Id.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
template.Name.Contains("ani", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatCount(int value)
|
||||||
|
{
|
||||||
|
return value.ToString("N0", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatRate(double value)
|
||||||
|
{
|
||||||
|
return Math.Round(value, 1, MidpointRounding.AwayFromZero).ToString("0.0", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetAliases(IDictionary<string, string> values, string value, params string[] keys)
|
||||||
|
{
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
values[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetOptionalAliases(IDictionary<string, string> values, string? value, params string[] keys)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
SetAliases(values, value, keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetRankAliases(
|
||||||
|
IDictionary<string, string> values,
|
||||||
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
|
||||||
|
string rankDisplay,
|
||||||
|
string rankImagePath,
|
||||||
|
params string[] keys)
|
||||||
|
{
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sceneVariables.Count > 0 &&
|
||||||
|
sceneVariables.TryGetValue(key, out var variable) &&
|
||||||
|
variable.Kind == KarismaSceneVariableKind.Image &&
|
||||||
|
!string.IsNullOrWhiteSpace(rankImagePath))
|
||||||
|
{
|
||||||
|
values[key] = rankImagePath;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
values[key] = rankDisplay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> FilterValuesForScene(
|
||||||
|
Dictionary<string, string> values,
|
||||||
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
|
{
|
||||||
|
if (sceneVariables.Count == 0)
|
||||||
|
{
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
.Where(pair => sceneVariables.ContainsKey(pair.Key))
|
||||||
|
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveAssetAcrossRoots(string t3CutPath, string templateFolderPath, IEnumerable<string> relativePaths)
|
||||||
|
{
|
||||||
|
var candidates = relativePaths
|
||||||
|
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (candidates.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var root in BuildAssetRoots(t3CutPath, templateFolderPath))
|
||||||
|
{
|
||||||
|
foreach (var relativePath in candidates)
|
||||||
|
{
|
||||||
|
var fullPath = Path.Combine(root, relativePath);
|
||||||
|
if (File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> BuildAssetRoots(string t3CutPath, string templateFolderPath)
|
||||||
|
{
|
||||||
|
var roots = new List<string>();
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
AddRoot(templateFolderPath);
|
||||||
|
|
||||||
|
var current = templateFolderPath;
|
||||||
|
while (!string.IsNullOrWhiteSpace(current))
|
||||||
|
{
|
||||||
|
var parent = Path.GetDirectoryName(current);
|
||||||
|
if (string.IsNullOrWhiteSpace(parent) ||
|
||||||
|
!parent.StartsWith(t3CutPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddRoot(parent);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(t3CutPath))
|
||||||
|
{
|
||||||
|
foreach (var directory in Directory.GetDirectories(t3CutPath))
|
||||||
|
{
|
||||||
|
AddRoot(directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
|
||||||
|
void AddRoot(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedPath = Path.GetFullPath(path);
|
||||||
|
if (seen.Add(normalizedPath))
|
||||||
|
{
|
||||||
|
roots.Add(normalizedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetPartyFileNameCandidates(string partyName)
|
||||||
|
{
|
||||||
|
var trimmed = partyName.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return trimmed;
|
||||||
|
|
||||||
|
var noSpaces = trimmed.Replace(" ", string.Empty);
|
||||||
|
if (!string.Equals(noSpaces, trimmed, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
yield return noSpaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(trimmed, "무소속", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
yield return "무소속기타";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum PartyAssetKind
|
||||||
|
{
|
||||||
|
Bar,
|
||||||
|
Plate,
|
||||||
|
Symbol,
|
||||||
|
Group
|
||||||
|
}
|
||||||
|
|
||||||
private readonly record struct KarismaChannelBinding(int OutputChannelIndex, int LayerNo);
|
private readonly record struct KarismaChannelBinding(int OutputChannelIndex, int LayerNo);
|
||||||
|
|
||||||
private readonly record struct ResolvedScene(string Path, string Alias);
|
private readonly record struct ResolvedScene(string Path, string Alias);
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ public sealed class TornadoManager : IDisposable
|
|||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ApplyValuesAsync(string sceneAlias, IReadOnlyDictionary<string, string> values, CancellationToken cancellationToken)
|
public Task ApplyValuesAsync(
|
||||||
|
string sceneAlias,
|
||||||
|
IReadOnlyDictionary<string, string> values,
|
||||||
|
IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return _dispatcher.InvokeAsync(() =>
|
return _dispatcher.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
@@ -122,6 +126,32 @@ public sealed class TornadoManager : IDisposable
|
|||||||
_logService.Warning($"Karisma object update skipped: scene={sceneAlias} object={pair.Key} reason={ex.Message}");
|
_logService.Warning($"Karisma object update skipped: scene={sceneAlias} object={pair.Key} reason={ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var counterNumberKey in counterNumberKeys)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(counterNumberKey.ObjectName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sceneObject = scene.GetObject(counterNumberKey.ObjectName);
|
||||||
|
if (sceneObject is not IKACounter counter)
|
||||||
|
{
|
||||||
|
_logService.Warning(
|
||||||
|
$"Karisma counter update skipped: scene={sceneAlias} object={counterNumberKey.ObjectName} reason=object is not a counter");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
counter.SetCounterNumberKey(counterNumberKey.KeyIndex, counterNumberKey.Number);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logService.Warning(
|
||||||
|
$"Karisma counter update skipped: scene={sceneAlias} object={counterNumberKey.ObjectName} keyIndex={counterNumberKey.KeyIndex} reason={ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
|
|
||||||
Candidates =
|
Candidates =
|
||||||
[
|
[
|
||||||
new CandidateEntry { CandidateCode = "A01", Name = "김후보", Party = "미래연합", VoteCount = 312000, VoteRate = 34.8, HasImage = true },
|
new CandidateEntry { CandidateCode = "A01", Name = "김후보", Party = "더불어민주당", VoteCount = 312000, VoteRate = 34.8, HasImage = true },
|
||||||
new CandidateEntry { CandidateCode = "A02", Name = "이후보", Party = "국민실행", VoteCount = 287000, VoteRate = 32.0, HasImage = true },
|
new CandidateEntry { CandidateCode = "A02", Name = "이후보", Party = "국민의힘", VoteCount = 287000, VoteRate = 32.0, HasImage = true },
|
||||||
new CandidateEntry { CandidateCode = "A03", Name = "이서윤", Party = "정의미래", VoteCount = 168000, VoteRate = 18.7, HasImage = false },
|
new CandidateEntry { CandidateCode = "A03", Name = "이서윤", Party = "개혁신당", VoteCount = 168000, VoteRate = 18.7, HasImage = false },
|
||||||
new CandidateEntry { CandidateCode = "A04", Name = "정민석", Party = "무소속", VoteCount = 129000, VoteRate = 14.5, HasImage = true }
|
new CandidateEntry { CandidateCode = "A04", Name = "정민석", Party = "무소속", VoteCount = 129000, VoteRate = 14.5, HasImage = true }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<KarismaSdkDir Condition="'$(KarismaSdkDir)'==''">C:\Karisma SDK</KarismaSdkDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Interop.KAsyncEngineLib">
|
||||||
|
<HintPath>$(KarismaSdkDir)\Bin\C#\Interop.KAsyncEngineLib.dll</HintPath>
|
||||||
|
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||||
|
<Private>true</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
597
tools/KarismaSceneCatalogNet48/Program.cs
Normal file
597
tools/KarismaSceneCatalogNet48/Program.cs
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using KAsyncEngineLib;
|
||||||
|
|
||||||
|
namespace KarismaSceneCatalogNet48;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
private static int Main(string[] args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = CatalogOptions.Parse(args);
|
||||||
|
var session = new CatalogSession(options);
|
||||||
|
return session.Run();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(ex);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CatalogSession
|
||||||
|
{
|
||||||
|
private readonly CatalogOptions _options;
|
||||||
|
private readonly CatalogEventHandler _handler;
|
||||||
|
private readonly List<SceneCatalogEntry> _entries = new List<SceneCatalogEntry>();
|
||||||
|
private readonly List<SceneCatalogFailure> _failures = new List<SceneCatalogFailure>();
|
||||||
|
|
||||||
|
private IKAEngine _engine;
|
||||||
|
private IKAScene _sceneToQueryOnLoad;
|
||||||
|
|
||||||
|
private bool _connectReceived;
|
||||||
|
private int _connectErrorCode;
|
||||||
|
private bool _closeReceived;
|
||||||
|
|
||||||
|
private bool _loadReceived;
|
||||||
|
private eKResult _loadResult;
|
||||||
|
|
||||||
|
private bool _objectInfosReceived;
|
||||||
|
private ObjectInfosResult _objectInfosResult;
|
||||||
|
|
||||||
|
private bool _unloadReceived;
|
||||||
|
|
||||||
|
public CatalogSession(CatalogOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_handler = new CatalogEventHandler(this);
|
||||||
|
_engine = (IKAEngine)new KAEngineClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Run()
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
"Karisma scene catalog starting. target={0}:{1} root={2} output={3}",
|
||||||
|
_options.Host,
|
||||||
|
_options.Port,
|
||||||
|
_options.RootPath,
|
||||||
|
_options.OutputPath);
|
||||||
|
|
||||||
|
if (!ProbeRawTcp())
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("[CATALOG48] Calling Connect()...");
|
||||||
|
var connectRequested = _engine.Connect(_options.Host, _options.Port, _handler);
|
||||||
|
Console.WriteLine("[CATALOG48] Connect() returned {0} raw={1}", connectRequested != 0 ? "TRUE" : "FALSE", connectRequested);
|
||||||
|
if (connectRequested == 0)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WaitWithMessagePump(() => _connectReceived, _options.Timeout))
|
||||||
|
{
|
||||||
|
Console.WriteLine("[CATALOG48] OnConnect timed out.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_connectErrorCode != 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[CATALOG48] OnConnect errorCode={0}", _connectErrorCode);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scenePaths = Directory
|
||||||
|
.EnumerateFiles(_options.RootPath, "*.tscn", SearchOption.AllDirectories)
|
||||||
|
.Where(path => string.IsNullOrWhiteSpace(_options.SceneFilter) ||
|
||||||
|
path.IndexOf(_options.SceneFilter, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(_options.MaxScenes > 0 ? _options.MaxScenes : int.MaxValue)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Console.WriteLine("[CATALOG48] Scene count={0}", scenePaths.Length);
|
||||||
|
|
||||||
|
for (var index = 0; index < scenePaths.Length; index++)
|
||||||
|
{
|
||||||
|
var scenePath = scenePaths[index];
|
||||||
|
var sceneAlias = string.Format("catalog48_{0:D4}", index);
|
||||||
|
Console.WriteLine("[CATALOG48] ({0}/{1}) {2}", index + 1, scenePaths.Length, scenePath);
|
||||||
|
|
||||||
|
ResetSceneState();
|
||||||
|
|
||||||
|
IKAScene scene = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scene = _engine.LoadScene(scenePath, sceneAlias);
|
||||||
|
if (scene == null)
|
||||||
|
{
|
||||||
|
_failures.Add(new SceneCatalogFailure(scenePath, "LoadScene returned null."));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sceneToQueryOnLoad = scene;
|
||||||
|
|
||||||
|
if (!WaitWithMessagePump(() => _loadReceived, _options.Timeout))
|
||||||
|
{
|
||||||
|
_failures.Add(new SceneCatalogFailure(scenePath, "OnLoadScene timed out."));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_loadResult != eKResult.RESULT_SUCCESS)
|
||||||
|
{
|
||||||
|
_failures.Add(new SceneCatalogFailure(scenePath, "OnLoadScene result=" + _loadResult));
|
||||||
|
TryUnload(scene);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WaitWithMessagePump(() => _objectInfosReceived, _options.Timeout))
|
||||||
|
{
|
||||||
|
_failures.Add(new SceneCatalogFailure(scenePath, "OnQueryObjectInfos timed out."));
|
||||||
|
TryUnload(scene);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_objectInfosResult.Result != eKResult.RESULT_SUCCESS)
|
||||||
|
{
|
||||||
|
_failures.Add(new SceneCatalogFailure(
|
||||||
|
scenePath,
|
||||||
|
string.IsNullOrWhiteSpace(_objectInfosResult.Detail)
|
||||||
|
? "OnQueryObjectInfos result=" + _objectInfosResult.Result
|
||||||
|
: "OnQueryObjectInfos result=" + _objectInfosResult.Result + " detail=" + _objectInfosResult.Detail));
|
||||||
|
TryUnload(scene);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_entries.Add(new SceneCatalogEntry(
|
||||||
|
scenePath,
|
||||||
|
Path.GetRelativePath(_options.RootPath, scenePath),
|
||||||
|
_objectInfosResult.Objects));
|
||||||
|
|
||||||
|
TryUnload(scene);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_failures.Add(new SceneCatalogFailure(scenePath, ex.Message));
|
||||||
|
if (scene != null)
|
||||||
|
{
|
||||||
|
TryUnload(scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteMarkdown();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_engine.Disconnect();
|
||||||
|
WaitWithMessagePump(() => _closeReceived, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Summary");
|
||||||
|
Console.WriteLine("- Scenes Found: {0}", scenePaths.Length);
|
||||||
|
Console.WriteLine("- Scenes Cataloged: {0}", _entries.Count);
|
||||||
|
Console.WriteLine("- Failures: {0}", _failures.Count);
|
||||||
|
Console.WriteLine("- Output: {0}", _options.OutputPath);
|
||||||
|
return _failures.Count == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HandleConnect(int errorCode)
|
||||||
|
{
|
||||||
|
_connectReceived = true;
|
||||||
|
_connectErrorCode = errorCode;
|
||||||
|
Console.WriteLine("[SDK] OnConnect errorCode={0}", errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HandleClose(int errorCode)
|
||||||
|
{
|
||||||
|
_closeReceived = true;
|
||||||
|
Console.WriteLine("[SDK] OnClose errorCode={0}", errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HandleLoadScene(eKResult result, string sceneName)
|
||||||
|
{
|
||||||
|
_loadReceived = true;
|
||||||
|
_loadResult = result;
|
||||||
|
Console.WriteLine("[SDK] OnLoadScene result={0} scene={1}", result, sceneName);
|
||||||
|
|
||||||
|
if (result != eKResult.RESULT_SUCCESS || _sceneToQueryOnLoad == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sceneToQueryOnLoad.QueryObjectInfos();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_objectInfosReceived = true;
|
||||||
|
_objectInfosResult = new ObjectInfosResult(eKResult.RESULT_FAILURE, sceneName, new List<SceneObjectInfo>(), ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_sceneToQueryOnLoad = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HandleQueryObjectInfos(eKResult result, string sceneName, KAObjectInfos objectInfos)
|
||||||
|
{
|
||||||
|
var objects = new List<SceneObjectInfo>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (result == eKResult.RESULT_SUCCESS)
|
||||||
|
{
|
||||||
|
var count = objectInfos.GetCount();
|
||||||
|
for (var index = 0; index < count; index++)
|
||||||
|
{
|
||||||
|
var info = objectInfos.GetObjectInfo(index);
|
||||||
|
objects.Add(new SceneObjectInfo(
|
||||||
|
info.Name ?? string.Empty,
|
||||||
|
info.ObjectType,
|
||||||
|
info.Value ?? string.Empty,
|
||||||
|
info.bVisible != 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("[SDK] OnQueryObjectInfos result={0} scene={1} count={2}", result, sceneName, objects.Count);
|
||||||
|
_objectInfosResult = new ObjectInfosResult(result, sceneName, objects, string.Empty);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_objectInfosResult = new ObjectInfosResult(eKResult.RESULT_FAILURE, sceneName, objects, ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_objectInfosReceived = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HandleUnloadScene(eKResult result, string sceneName)
|
||||||
|
{
|
||||||
|
_unloadReceived = true;
|
||||||
|
Console.WriteLine("[SDK] OnUnloadScene result={0} scene={1}", result, sceneName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ProbeRawTcp()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var client = new TcpClient())
|
||||||
|
{
|
||||||
|
var asyncResult = client.BeginConnect(_options.Host, _options.Port, null, null);
|
||||||
|
if (!asyncResult.AsyncWaitHandle.WaitOne(_options.Timeout))
|
||||||
|
{
|
||||||
|
Console.WriteLine("[RAW] Failed: timeout");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.EndConnect(asyncResult);
|
||||||
|
Console.WriteLine("[RAW] Connected local={0} remote={1}", client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[RAW] Failed: {0}", ex.Message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetSceneState()
|
||||||
|
{
|
||||||
|
_sceneToQueryOnLoad = null;
|
||||||
|
_loadReceived = false;
|
||||||
|
_loadResult = eKResult.RESULT_FAILURE;
|
||||||
|
_objectInfosReceived = false;
|
||||||
|
_objectInfosResult = new ObjectInfosResult(eKResult.RESULT_FAILURE, string.Empty, new List<SceneObjectInfo>(), string.Empty);
|
||||||
|
_unloadReceived = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryUnload(IKAScene scene)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_unloadReceived = false;
|
||||||
|
scene.UnloadScene();
|
||||||
|
WaitWithMessagePump(() => _unloadReceived, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteMarkdown()
|
||||||
|
{
|
||||||
|
var outputDirectory = Path.GetDirectoryName(_options.OutputPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var writer = new StreamWriter(_options.OutputPath, false, new UTF8Encoding(false)))
|
||||||
|
{
|
||||||
|
writer.WriteLine("# Scene Object Catalog");
|
||||||
|
writer.WriteLine();
|
||||||
|
writer.WriteLine("- Generated: {0:yyyy-MM-dd HH:mm:ss}", DateTime.Now);
|
||||||
|
writer.WriteLine("- Root: `{0}`", _options.RootPath);
|
||||||
|
writer.WriteLine("- Scene Count: {0}", _entries.Count);
|
||||||
|
writer.WriteLine("- Failure Count: {0}", _failures.Count);
|
||||||
|
writer.WriteLine();
|
||||||
|
|
||||||
|
if (_failures.Count > 0)
|
||||||
|
{
|
||||||
|
writer.WriteLine("## Failures");
|
||||||
|
writer.WriteLine();
|
||||||
|
foreach (var failure in _failures)
|
||||||
|
{
|
||||||
|
writer.WriteLine("- `{0}`: {1}", failure.ScenePath, EscapeCell(failure.Reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteLine("## Scenes");
|
||||||
|
writer.WriteLine();
|
||||||
|
foreach (var entry in _entries)
|
||||||
|
{
|
||||||
|
writer.WriteLine("### `{0}`", entry.RelativePath);
|
||||||
|
writer.WriteLine();
|
||||||
|
writer.WriteLine("- Object Count: {0}", entry.Objects.Count);
|
||||||
|
writer.WriteLine();
|
||||||
|
writer.WriteLine("| Name | Type | Value | Visible |");
|
||||||
|
writer.WriteLine("| --- | --- | --- | --- |");
|
||||||
|
foreach (var obj in entry.Objects)
|
||||||
|
{
|
||||||
|
writer.WriteLine(
|
||||||
|
"| {0} | {1} | {2} | {3} |",
|
||||||
|
EscapeCell(obj.Name),
|
||||||
|
obj.ObjectType,
|
||||||
|
EscapeCell(obj.Value),
|
||||||
|
obj.Visible ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool WaitWithMessagePump(Func<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
User32.PeekMessage(out _, IntPtr.Zero, 0, 0, 0);
|
||||||
|
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (!condition())
|
||||||
|
{
|
||||||
|
while (User32.PeekMessage(out var message, IntPtr.Zero, 0, 0, 1))
|
||||||
|
{
|
||||||
|
User32.TranslateMessage(ref message);
|
||||||
|
User32.DispatchMessage(ref message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.UtcNow >= deadline)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeCell(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Replace("\\", "\\\\")
|
||||||
|
.Replace("|", "\\|")
|
||||||
|
.Replace("\r", " ")
|
||||||
|
.Replace("\n", "<br/>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CatalogEventHandler : KAEventHandler
|
||||||
|
{
|
||||||
|
private readonly CatalogSession _owner;
|
||||||
|
|
||||||
|
public CatalogEventHandler(CatalogSession owner)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnConnect(int errorCode) => _owner.HandleConnect(errorCode);
|
||||||
|
|
||||||
|
public void OnClose(int errorCode) => _owner.HandleClose(errorCode);
|
||||||
|
|
||||||
|
public void OnLoadScene(eKResult result, string sceneName) => _owner.HandleLoadScene(result, sceneName);
|
||||||
|
|
||||||
|
public void OnUnloadScene(eKResult result, string sceneName) => _owner.HandleUnloadScene(result, sceneName);
|
||||||
|
|
||||||
|
public void OnQueryObjectInfos(eKResult result, string sceneName, KAObjectInfos objectInfos) =>
|
||||||
|
_owner.HandleQueryObjectInfos(result, sceneName, objectInfos);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CatalogOptions
|
||||||
|
{
|
||||||
|
public string Host { get; private set; }
|
||||||
|
public int Port { get; private set; }
|
||||||
|
public TimeSpan Timeout { get; private set; }
|
||||||
|
public string RootPath { get; private set; }
|
||||||
|
public string OutputPath { get; private set; }
|
||||||
|
public string SceneFilter { get; private set; }
|
||||||
|
public int MaxScenes { get; private set; }
|
||||||
|
|
||||||
|
private 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");
|
||||||
|
OutputPath = Path.Combine(Environment.CurrentDirectory, "SCENE_OBJECT_CATALOG.md");
|
||||||
|
SceneFilter = string.Empty;
|
||||||
|
MaxScenes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CatalogOptions Parse(string[] args)
|
||||||
|
{
|
||||||
|
var options = new CatalogOptions();
|
||||||
|
|
||||||
|
for (var index = 0; index < args.Length; index++)
|
||||||
|
{
|
||||||
|
switch (args[index])
|
||||||
|
{
|
||||||
|
case "--host" when index + 1 < args.Length:
|
||||||
|
options.Host = args[++index];
|
||||||
|
break;
|
||||||
|
case "--port" when index + 1 < args.Length && int.TryParse(args[index + 1], out var port):
|
||||||
|
options.Port = port;
|
||||||
|
index++;
|
||||||
|
break;
|
||||||
|
case "--timeout" when index + 1 < args.Length && double.TryParse(args[index + 1], out var timeoutSeconds):
|
||||||
|
options.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||||
|
index++;
|
||||||
|
break;
|
||||||
|
case "--root" when index + 1 < args.Length:
|
||||||
|
options.RootPath = Path.GetFullPath(args[++index]);
|
||||||
|
break;
|
||||||
|
case "--output" when index + 1 < args.Length:
|
||||||
|
options.OutputPath = Path.GetFullPath(args[++index]);
|
||||||
|
break;
|
||||||
|
case "--filter" when index + 1 < args.Length:
|
||||||
|
options.SceneFilter = args[++index];
|
||||||
|
break;
|
||||||
|
case "--max-scenes" when index + 1 < args.Length && int.TryParse(args[index + 1], out var maxScenes):
|
||||||
|
options.MaxScenes = maxScenes;
|
||||||
|
index++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(options.RootPath))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException("Catalog root path does not exist: " + options.RootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SceneCatalogEntry
|
||||||
|
{
|
||||||
|
public SceneCatalogEntry(string scenePath, string relativePath, IReadOnlyList<SceneObjectInfo> objects)
|
||||||
|
{
|
||||||
|
ScenePath = scenePath;
|
||||||
|
RelativePath = relativePath;
|
||||||
|
Objects = objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ScenePath { get; }
|
||||||
|
|
||||||
|
public string RelativePath { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<SceneObjectInfo> Objects { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SceneCatalogFailure
|
||||||
|
{
|
||||||
|
public SceneCatalogFailure(string scenePath, string reason)
|
||||||
|
{
|
||||||
|
ScenePath = scenePath;
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ScenePath { get; }
|
||||||
|
|
||||||
|
public string Reason { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SceneObjectInfo
|
||||||
|
{
|
||||||
|
public SceneObjectInfo(string name, eKObjectType objectType, string value, bool visible)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
ObjectType = objectType;
|
||||||
|
Value = value;
|
||||||
|
Visible = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public eKObjectType ObjectType { get; }
|
||||||
|
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
public bool Visible { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ObjectInfosResult
|
||||||
|
{
|
||||||
|
public ObjectInfosResult(eKResult result, string sceneName, IReadOnlyList<SceneObjectInfo> objects, string detail)
|
||||||
|
{
|
||||||
|
Result = result;
|
||||||
|
SceneName = sceneName;
|
||||||
|
Objects = objects;
|
||||||
|
Detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public eKResult Result { get; }
|
||||||
|
|
||||||
|
public string SceneName { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<SceneObjectInfo> Objects { get; }
|
||||||
|
|
||||||
|
public string Detail { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct NativeMessage
|
||||||
|
{
|
||||||
|
public IntPtr hwnd;
|
||||||
|
public uint message;
|
||||||
|
public UIntPtr wParam;
|
||||||
|
public IntPtr lParam;
|
||||||
|
public uint time;
|
||||||
|
public NativePoint pt;
|
||||||
|
public uint lPrivate;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct NativePoint
|
||||||
|
{
|
||||||
|
public int x;
|
||||||
|
public int y;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class User32
|
||||||
|
{
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern bool PeekMessage(out NativeMessage lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern bool TranslateMessage([In] ref NativeMessage lpMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern IntPtr DispatchMessage([In] ref NativeMessage lpmsg);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
21
tools/KarismaTcpProbe/scene-ops/1-2위_ani_광역단체장.json
Normal file
21
tools/KarismaTcpProbe/scene-ops/1-2위_ani_광역단체장.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[
|
||||||
|
{ "objectName": "Image", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Etc\\가이드.png" },
|
||||||
|
{ "objectName": "시도명", "method": "SetValue", "value": "서울특별시" },
|
||||||
|
{ "objectName": "표차", "method": "SetValue", "value": "100,000표차" },
|
||||||
|
|
||||||
|
{ "objectName": "유확당01", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Tag\\당선.vrv" },
|
||||||
|
{ "objectName": "순위01", "method": "SetValue", "value": "1" },
|
||||||
|
{ "objectName": "정당명01", "method": "SetValue", "value": "더불어민주당" },
|
||||||
|
{ "objectName": "후보명01", "method": "SetValue", "value": "김후보" },
|
||||||
|
{ "objectName": "득표수01", "method": "SetValue", "value": "2,123,456" },
|
||||||
|
{ "objectName": "후보사진01", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Photo\\sampleNEW.png" },
|
||||||
|
{ "objectName": "득표율01", "method": "SetCounterNumberKey", "keyIndex": 1, "number": 30 },
|
||||||
|
|
||||||
|
{ "objectName": "유확당02", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Tag\\당선.vrv" },
|
||||||
|
{ "objectName": "순위02", "method": "SetValue", "value": "2" },
|
||||||
|
{ "objectName": "정당명02", "method": "SetValue", "value": "국민의힘" },
|
||||||
|
{ "objectName": "후보명02", "method": "SetValue", "value": "이후보" },
|
||||||
|
{ "objectName": "득표수02", "method": "SetValue", "value": "1,123,456" },
|
||||||
|
{ "objectName": "후보사진02", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Photo\\sampleNEW.png" },
|
||||||
|
{ "objectName": "득표율02", "method": "SetCounterNumberKey", "keyIndex": 1, "number": 20 }
|
||||||
|
]
|
||||||
25
tools/KarismaTcpProbe/scene-ops/1-2위_ani_광역단체장_live.json
Normal file
25
tools/KarismaTcpProbe/scene-ops/1-2위_ani_광역단체장_live.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[
|
||||||
|
{ "objectName": "\uAC1C\uD45C\uC72801", "method": "SetValue", "value": "88.8" },
|
||||||
|
{ "objectName": "\uC2DC\uB3C4\uBA8501", "method": "SetValue", "value": "\uC11C\uC6B8\uD2B9\uBCC4\uC2DC" },
|
||||||
|
{ "objectName": "\uD45C\uCC2801", "method": "SetValue", "value": "25,000" },
|
||||||
|
|
||||||
|
{ "objectName": "\uC720\uD655\uB2F901", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Tag\\\uB2F9\uC120.vrv" },
|
||||||
|
{ "objectName": "\uC21C\uC70401", "method": "SetValue", "value": "1" },
|
||||||
|
{ "objectName": "\uC815\uB2F9\uBA8501", "method": "SetValue", "value": "\uB354\uBD88\uC5B4\uBBFC\uC8FC\uB2F9" },
|
||||||
|
{ "objectName": "\uD6C4\uBCF4\uBA8501", "method": "SetValue", "value": "\uAE40\uD6C4\uBCF4" },
|
||||||
|
{ "objectName": "\uB4DD\uD45C\uC21801", "method": "SetValue", "value": "2,123,456" },
|
||||||
|
{ "objectName": "\uC815\uB2F9\uBC1401", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Dang\\Dang_Map\\\uB354\uBD88\uC5B4\uBBFC\uC8FC\uB2F9.png" },
|
||||||
|
{ "objectName": "\uC815\uB2F9\uD31001", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Dang\\Dang_Round\\\uB354\uBD88\uC5B4\uBBFC\uC8FC\uB2F9.png" },
|
||||||
|
{ "objectName": "\uD6C4\uBCF4\uC0AC\uC9C401", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Photo\\sampleNEW.png" },
|
||||||
|
{ "objectName": "\uB4DD\uD45C\uC72801", "method": "SetCounterNumberKey", "keyIndex": 1, "number": 34.8 },
|
||||||
|
|
||||||
|
{ "objectName": "\uC720\uD655\uB2F902", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Tag\\\uC720\uB825.vrv" },
|
||||||
|
{ "objectName": "\uC21C\uC70402", "method": "SetValue", "value": "2" },
|
||||||
|
{ "objectName": "\uC815\uB2F9\uBA8502", "method": "SetValue", "value": "\uAD6D\uBBFC\uC758\uD798" },
|
||||||
|
{ "objectName": "\uD6C4\uBCF4\uBA8502", "method": "SetValue", "value": "\uC774\uD6C4\uBCF4" },
|
||||||
|
{ "objectName": "\uB4DD\uD45C\uC21802", "method": "SetValue", "value": "1,123,456" },
|
||||||
|
{ "objectName": "\uC815\uB2F9\uBC1402", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Dang\\Dang_Map\\\uAD6D\uBBFC\uC758\uD798.png" },
|
||||||
|
{ "objectName": "\uC815\uB2F9\uD31002", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Dang\\Dang_Round\\\uAD6D\uBBFC\uC758\uD798.png" },
|
||||||
|
{ "objectName": "\uD6C4\uBCF4\uC0AC\uC9C402", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Photo\\sampleNEW.png" },
|
||||||
|
{ "objectName": "\uB4DD\uD45C\uC72802", "method": "SetCounterNumberKey", "keyIndex": 1, "number": 32.0 }
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user