mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Ship LCK analytics inside k-skill's managed release flow
Adapt jerjangmin's upstream lck-analytics skill/package into a new
workspace and skill pack, wire docs/install/release surfaces, and add
regression fixtures/tests plus script smoke coverage so the feature is
verifiable before publish.
Constraint: Upstream package is not published to npm yet
Constraint: Must preserve attribution to original source and author in shipped docs
Rejected: Keep the upstream lck-results install wording in k-skill | conflicts with repo workspace/package naming
Rejected: Ship only the npm package without the local skill scripts | issue explicitly requested the skill as well
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep Changesets as the only version-bump path for lck-analytics; do not hand-edit versions for release
Tested: node --test packages/lck-analytics/test/index.test.js scripts/skill-docs.test.js
Tested: npm run lint --workspace lck-analytics
Tested: npm test --workspace lck-analytics
Tested: live getLckSummary('2026-04-01', { team: '한화', includeStandings: true })
Tested: node lck-analytics/scripts/sync-oracle.js --csv lck-analytics/samples/oracle-lck-sample.csv --cache .tmp/lck-cache && node lck-analytics/scripts/build-match-report.js --date 2026-04-01 --team 한화 --cache .tmp/lck-cache && node lck-analytics/scripts/analyze-live-game.js --game game-1 --window packages/lck-analytics/test/fixtures/live-window-game-1.json --details packages/lck-analytics/test/fixtures/live-details-game-1.json --cache .tmp/lck-cache
Tested: npm run ci
Tested: npx tsc --noEmit --project /Users/jeffrey/Projects/k-skill/tsconfig.json
Not-tested: Riot live feed behavior for arbitrary future game ids outside the fixture-backed smoke path
This commit is contained in:
parent
66000d92f2
commit
9fad8ae045
32 changed files with 3253 additions and 7 deletions
5
.changeset/bright-apricots-prove.md
Normal file
5
.changeset/bright-apricots-prove.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"lck-analytics": minor
|
||||
---
|
||||
|
||||
Add the first LCK analytics package and skill pack adapted from jerjangmin's original upstream implementation.
|
||||
|
|
@ -26,6 +26,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 한국 법령 검색 | `korean-law-mcp` 우선 + 장애 시 `법망` fallback으로 법령/조문/판례/유권해석 조회 | 로컬 CLI/MCP면 `LAW_OC` 필요, remote endpoint/법망 fallback은 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
|
||||
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
|
||||
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
|
||||
| LCK 경기 분석 | 날짜별 LCK 결과, 현재 순위, live turning point, 밴픽 matchup/synergy, patch meta, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
|
||||
| 토스증권 조회 | `tossctl` 기반 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
|
||||
| 로또 당첨 확인 | 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
|
||||
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리, Windows 직접 제어 선택 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
|
||||
|
|
@ -68,6 +69,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
|
||||
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
|
||||
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
|
||||
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
|
||||
- [토스증권 조회 가이드](docs/features/toss-securities.md)
|
||||
- [로또 당첨 확인](docs/features/lotto-results.md)
|
||||
- [HWP 문서 처리](docs/features/hwp.md)
|
||||
|
|
|
|||
106
docs/features/lck-analytics.md
Normal file
106
docs/features/lck-analytics.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# LCK 경기 분석
|
||||
|
||||
`lck-analytics` 는 Riot 공식 LoL Esports 표면과 Oracle's Elixir 스타일 historical 데이터셋을 함께 사용해 LCK 경기 결과와 고급 분석을 제공하는 스킬이다.
|
||||
|
||||
## Origin / attribution
|
||||
|
||||
- Original reference skill: <https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics>
|
||||
- Original author: `jerjangmin`
|
||||
- k-skill adaptation: 이 저장소의 npm workspace / Changesets 배포 흐름에 맞춰 패키지와 문서를 정리한 버전
|
||||
|
||||
## 무엇을 할 수 있나
|
||||
|
||||
- 날짜별 LCK 경기 결과 조회
|
||||
- 팀 alias 정규화 (`한화`, `HLE`, `SKT T1`, `DN FREECS`, `광동 프릭스` 등)
|
||||
- 해당 날짜 기준 현재 순위 조회
|
||||
- live game 킬 / 골드 / 오브젝트 / participant snapshot 조회
|
||||
- live timeline 기반 turning point 추정
|
||||
- Oracle's Elixir 스타일 CSV로부터
|
||||
- 팀 파워 레이팅
|
||||
- champion matchup / synergy
|
||||
- patch meta summary 계산
|
||||
|
||||
## 설치
|
||||
|
||||
```bash
|
||||
npm install -g lck-analytics
|
||||
export NODE_PATH="$(npm root -g)"
|
||||
```
|
||||
|
||||
## 기본 조회 예시
|
||||
|
||||
```bash
|
||||
GLOBAL_NPM_ROOT="$(npm root -g)" node --input-type=module - <<'JS'
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const entry = pathToFileURL(
|
||||
path.join(process.env.GLOBAL_NPM_ROOT, "lck-analytics", "src", "index.js"),
|
||||
).href;
|
||||
const { getLckSummary } = await import(entry);
|
||||
|
||||
const summary = await getLckSummary("2026-04-01", {
|
||||
team: "한화",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
JS
|
||||
```
|
||||
|
||||
## Local pipeline
|
||||
|
||||
skill directory 안에는 원본 pack을 따라 local helper script도 포함한다.
|
||||
|
||||
- `lck-analytics/scripts/sync-oracle.js`
|
||||
- `lck-analytics/scripts/build-match-report.js`
|
||||
- `lck-analytics/scripts/analyze-live-game.js`
|
||||
- `lck-analytics/samples/oracle-lck-sample.csv`
|
||||
|
||||
historical cache 생성:
|
||||
|
||||
```bash
|
||||
node ./lck-analytics/scripts/sync-oracle.js \
|
||||
--csv ./lck-analytics/samples/oracle-lck-sample.csv
|
||||
```
|
||||
|
||||
날짜별 match analysis 생성:
|
||||
|
||||
```bash
|
||||
node ./lck-analytics/scripts/build-match-report.js \
|
||||
--date 2026-04-01 \
|
||||
--team 한화
|
||||
```
|
||||
|
||||
live turning point 분석:
|
||||
|
||||
```bash
|
||||
node ./lck-analytics/scripts/analyze-live-game.js \
|
||||
--game game-id
|
||||
```
|
||||
|
||||
## Official surfaces
|
||||
|
||||
- `https://esports-api.lolesports.com/persisted/gw/getSchedule`
|
||||
- `https://esports-api.lolesports.com/persisted/gw/getTournamentsForLeague`
|
||||
- `https://esports-api.lolesports.com/persisted/gw/getStandings`
|
||||
- `https://esports-api.lolesports.com/persisted/gw/getEventDetails`
|
||||
- `https://feed.lolesports.com/livestats/v1/window/{gameId}`
|
||||
- `https://feed.lolesports.com/livestats/v1/details/{gameId}`
|
||||
- Oracle's Elixir downloads / schema reference: <https://oracleselixir.com/tools/downloads>
|
||||
|
||||
## Release / publish note
|
||||
|
||||
이 기능은 `packages/lck-analytics` workspace로 추가됐다. 따라서 `main` 에 기능 PR이 merge되면:
|
||||
|
||||
1. `.changeset/*.md` 가 Version Packages PR을 생성하고
|
||||
2. 그 PR merge 뒤
|
||||
3. npm publish workflow가 `lck-analytics` 패키지를 배포한다
|
||||
|
||||
즉, main merge 직후 바로 태그를 수동으로 만들지 말고 기존 Changesets 릴리스 흐름을 따른다.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Riot web app용 공개 API key fallback은 회전될 수 있으므로, 필요하면 `LOLESPORTS_API_KEY` 환경변수로 override한다
|
||||
- turning point는 live snapshot 기반 heuristic 이라 VOD/GRID 레벨 정밀 분석과 동일하지 않다
|
||||
- historical 분석 품질은 Oracle-style row sample 수에 크게 좌우된다
|
||||
|
|
@ -47,6 +47,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill hwp \
|
||||
--skill kbo-results \
|
||||
--skill kleague-results \
|
||||
--skill lck-analytics \
|
||||
--skill toss-securities \
|
||||
--skill lotto-results \
|
||||
--skill kakaotalk-mac \
|
||||
|
|
@ -121,7 +122,7 @@ npm run ci
|
|||
### Node 패키지
|
||||
|
||||
```bash
|
||||
npm install -g @ohah/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp
|
||||
npm install -g @ohah/hwpjs kbo-game kleague-results lck-analytics toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp
|
||||
export NODE_PATH="$(npm root -g)"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
- KTX
|
||||
- KBO 경기 결과
|
||||
- K리그 경기 결과 조회 스킬 출시
|
||||
- LCK 경기 분석 스킬 출시
|
||||
- 토스증권 조회 스킬 출시
|
||||
- 로또 당첨번호
|
||||
- 서울 지하철 도착 정보
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@
|
|||
- tossinvest-cli: https://github.com/JungHoonGhae/tossinvest-cli
|
||||
- K League 일정/결과 JSON: https://www.kleague.com/getScheduleList.do
|
||||
- K League 팀 순위 JSON: https://www.kleague.com/record/teamRank.do
|
||||
- jerjangmin original `lck-analytics` skill pack: https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics
|
||||
- Riot LoL Esports schedule API: https://esports-api.lolesports.com/persisted/gw/getSchedule
|
||||
- Riot LoL Esports tournaments API: https://esports-api.lolesports.com/persisted/gw/getTournamentsForLeague
|
||||
- Riot LoL Esports standings API: https://esports-api.lolesports.com/persisted/gw/getStandings
|
||||
- Riot LoL Esports event details API: https://esports-api.lolesports.com/persisted/gw/getEventDetails
|
||||
- Riot LoL Esports live window feed: https://feed.lolesports.com/livestats/v1/window/<gameId>
|
||||
- Riot LoL Esports live details feed: https://feed.lolesports.com/livestats/v1/details/<gameId>
|
||||
- Oracle's Elixir data glossary: https://oracleselixir.com/tools/downloads
|
||||
- `@ohah/hwpjs`: https://github.com/ohah/hwpjs
|
||||
- `hwp-mcp`: https://github.com/jkf87/hwp-mcp
|
||||
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
|
||||
|
|
|
|||
15
lck-analytics/README.md
Normal file
15
lck-analytics/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# LCK Analytics skill pack
|
||||
|
||||
k-skill 버전의 `lck-analytics` 스킬 팩입니다.
|
||||
|
||||
- Original source: <https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics>
|
||||
- Original author: `jerjangmin`
|
||||
- This repo adaptation: npm workspace / Changesets 릴리스 흐름에 맞춘 k-skill 배포용 패키징
|
||||
|
||||
포함 항목:
|
||||
|
||||
- `SKILL.md`: 에이전트에 바로 줄 수 있는 스킬 문서
|
||||
- `scripts/sync-oracle.js`: Oracle-style CSV → historical cache JSON
|
||||
- `scripts/build-match-report.js`: 날짜별 match analysis 생성
|
||||
- `scripts/analyze-live-game.js`: live game analysis 생성
|
||||
- `samples/oracle-lck-sample.csv`: local smoke test용 샘플 CSV
|
||||
202
lck-analytics/SKILL.md
Normal file
202
lck-analytics/SKILL.md
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
---
|
||||
name: lck-analytics
|
||||
description: Riot 공식 LoL Esports 데이터와 Oracle's Elixir 스타일 historical 데이터로 LCK 경기 결과, 현재 순위, live turning point, 밴픽 matchup/synergy, patch meta, 팀 파워 레이팅을 조회한다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: sports
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# LCK Results + Advanced Analysis
|
||||
|
||||
## What this skill does
|
||||
|
||||
이 스킬은 LCK 조회/분석 전용이다.
|
||||
|
||||
- 특정 날짜 LCK 경기 결과 조회
|
||||
- 특정 팀 alias 정규화 후 필터링
|
||||
- 현재 스플릿 순위 조회
|
||||
- 진행 중 경기 live stats 조회
|
||||
- live timeline 기반 turning point 분석
|
||||
- Oracle's Elixir 스타일 historical row / CSV 기반
|
||||
- 팀 파워 레이팅
|
||||
- 챔피언 matchup / synergy 분석
|
||||
- patch meta 요약
|
||||
- 날짜별 match analysis 생성
|
||||
|
||||
## Origin / attribution
|
||||
|
||||
이 스킬은 `jerjangmin` 님이 만든 원본 [`lck-analytics` skill pack](https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics)을 k-skill 저장소 안으로 옮기고, 이 저장소의 npm workspace / Changesets 배포 방식에 맞게 정리한 버전이다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "오늘 LCK 경기 결과 알려줘"
|
||||
- "2026-04-01 한화 경기 결과랑 순위 보여줘"
|
||||
- "지금 T1 경기 킬/골드/오브젝트 요약해줘"
|
||||
- "이 경기 turning point가 뭐였어?"
|
||||
- "이 밴픽에서 어느 쪽 조합이 더 좋았는지 설명해줘"
|
||||
- "현재 패치에서 어떤 챔피언이 메타 픽인지 보여줘"
|
||||
- "LCK 팀 파워 레이팅 보여줘"
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- `npm install -g lck-analytics`
|
||||
|
||||
패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치를 시도한다.
|
||||
|
||||
```bash
|
||||
npm install -g lck-analytics
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
### 기본 입력
|
||||
|
||||
- 날짜: `YYYY-MM-DD`
|
||||
- 선택 사항: 팀명, 과거 팀명, 한글/영문 약칭 alias
|
||||
|
||||
### 고급 분석 입력
|
||||
|
||||
- Oracle's Elixir 스타일 CSV 문자열 또는 row 배열
|
||||
- game id / match id
|
||||
- live window/details payload 또는 실시간 fetch 권한
|
||||
- patch version
|
||||
|
||||
## Team alias normalization
|
||||
|
||||
다음 이름들은 같은 canonical team 으로 인식한다.
|
||||
|
||||
- `DN SOOPers`
|
||||
- `DN FREECS`
|
||||
- `광동 프릭스`
|
||||
- `Afreeca Freecs`
|
||||
|
||||
추가로 `T1`, `SKT T1`, `담원`, `Dplus KIA`, `브리온`, `한화`, `젠지`, `피어엑스` 등도 alias 정규화를 지원한다.
|
||||
|
||||
## Official surfaces
|
||||
|
||||
이 스킬은 Riot 공식 / 공식 웹앱 표면을 우선 사용한다.
|
||||
|
||||
- 일정/결과: `getSchedule`
|
||||
- 토너먼트 목록: `getTournamentsForLeague`
|
||||
- 순위: `getStandings`
|
||||
- 이벤트 상세: `getEventDetails`
|
||||
- 라이브 window: `https://feed.lolesports.com/livestats/v1/window/{gameId}`
|
||||
- 라이브 details: `https://feed.lolesports.com/livestats/v1/details/{gameId}`
|
||||
|
||||
historical 고급 분석은 Oracle's Elixir 스타일 데이터 입력을 사용한다.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Included lightweight local pipeline
|
||||
|
||||
이 k-skill 팩에는 경량 로컬 파일 기반 파이프라인 스크립트가 포함된다.
|
||||
|
||||
- `scripts/sync-oracle.js`: Oracle-style CSV → historical cache JSON
|
||||
- `scripts/build-match-report.js`: 날짜별 match analysis 생성
|
||||
- `scripts/analyze-live-game.js`: game analysis 생성
|
||||
- 기본 cache 위치: `.openclaw-lck-cache/`
|
||||
|
||||
### 1. Basic scoreboard / standings query
|
||||
|
||||
```bash
|
||||
GLOBAL_NPM_ROOT="$(npm root -g)" node --input-type=module - <<'JS'
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const entry = pathToFileURL(
|
||||
path.join(process.env.GLOBAL_NPM_ROOT, "lck-analytics", "src", "index.js"),
|
||||
).href;
|
||||
const { getLckSummary } = await import(entry);
|
||||
|
||||
const summary = await getLckSummary("2026-04-01", {
|
||||
team: "한화",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
JS
|
||||
```
|
||||
|
||||
### 2. Historical analytics from Oracle-style CSV
|
||||
|
||||
직접 API를 호출해도 되지만, local skill pipeline에서는 아래 스크립트 사용을 우선 권장한다.
|
||||
|
||||
```bash
|
||||
node ./lck-analytics/scripts/sync-oracle.js \
|
||||
--csv ./lck-analytics/samples/oracle-lck-sample.csv
|
||||
```
|
||||
|
||||
### 3. Match analysis via local pipeline script
|
||||
|
||||
```bash
|
||||
node ./lck-analytics/scripts/build-match-report.js \
|
||||
--date 2026-04-01
|
||||
```
|
||||
|
||||
필요하면 팀 필터도 같이 준다.
|
||||
|
||||
```bash
|
||||
node ./lck-analytics/scripts/build-match-report.js \
|
||||
--date 2026-04-01 \
|
||||
--team 한화
|
||||
```
|
||||
|
||||
### 4. Game analysis with turning points via local pipeline script
|
||||
|
||||
```bash
|
||||
node ./lck-analytics/scripts/analyze-live-game.js \
|
||||
--game game-id
|
||||
```
|
||||
|
||||
fixture 기반으로 분석할 때는 `--window`, `--details` 를 같이 줄 수 있다.
|
||||
|
||||
## Output guidelines
|
||||
|
||||
사용자에게는 원본 JSON을 길게 그대로 던지지 말고 먼저 아래 순서로 정리한다.
|
||||
|
||||
### 경기 결과 요청
|
||||
|
||||
- 경기 시각
|
||||
- 팀1 vs 팀2
|
||||
- 상태
|
||||
- 세트 스코어
|
||||
- 요청 팀 경기만 있으면 해당 경기 우선
|
||||
- standings 요청이 있으면 현재 순위 같이 표시
|
||||
|
||||
### 진행 중 경기 요청
|
||||
|
||||
- 현재 게임 번호
|
||||
- 킬 차이
|
||||
- 골드 차이
|
||||
- 드래곤/바론/타워 차이
|
||||
- turning point 1~3개
|
||||
|
||||
### historical / meta 요청
|
||||
|
||||
- sample 수를 먼저 표시
|
||||
- 팀 파워 레이팅은 상위 팀부터 정렬
|
||||
- champion matchup / synergy는 표본 수가 적으면 낮은 확신도로 표시
|
||||
- patch meta는 top picks / risers 위주로 짧게 요약
|
||||
|
||||
## Done when
|
||||
|
||||
- 날짜 기준 경기 요약이 있다
|
||||
- 요청 팀 필터가 적용된다
|
||||
- standings 요청이면 현재 순위가 같이 정리된다
|
||||
- live 요청이면 현재 게임 요약과 turning point가 있다
|
||||
- historical 입력이 있으면 patch meta 또는 power rating까지 설명할 수 있다
|
||||
|
||||
## Failure modes
|
||||
|
||||
- Riot 웹앱 API 구조/헤더가 바뀌면 패키지 수정이 필요할 수 있다
|
||||
- `LOLESPORTS_API_KEY` public fallback이 회전되면 환경변수 override가 필요할 수 있다
|
||||
- historical CSV 컬럼명이 너무 다르면 Oracle-style 정규화 전에 전처리가 필요할 수 있다
|
||||
|
||||
## Notes
|
||||
|
||||
- 이 스킬은 조회/분석 전용이다
|
||||
- 사용자의 "오늘/어제" 요청은 항상 절대 날짜(`YYYY-MM-DD`)로 변환해서 실행한다
|
||||
- 이 저장소에서 `main` 으로 머지되면 Changesets가 Version Packages PR을 만들고, 그 PR이 merge된 뒤 npm publish가 실행된다
|
||||
5
lck-analytics/samples/oracle-lck-sample.csv
Normal file
5
lck-analytics/samples/oracle-lck-sample.csv
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
league,matchid,date,patch,side,teamname,opponentteam,playername,position,champion,opponentchampion,result,gd15,csd15,xpd15,drg,bn,blindpick,counterpick
|
||||
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Zeus,top,Aatrox,Gnar,win,1200,18,340,100,100,0,1
|
||||
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Peanut,jungle,Vi,Sejuani,win,800,5,280,100,100,1,0
|
||||
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Doran,top,Gnar,Aatrox,loss,-1200,-18,-340,0,0,1,0
|
||||
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Oner,jungle,Sejuani,Vi,loss,-800,-5,-280,0,0,0,1
|
||||
|
103
lck-analytics/scripts/_lib.js
Executable file
103
lck-analytics/scripts/_lib.js
Executable file
|
|
@ -0,0 +1,103 @@
|
|||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
async function loadLckResults() {
|
||||
const candidates = [];
|
||||
const packageNames = ["lck-analytics", "lck-results"];
|
||||
|
||||
if (process.env.GLOBAL_NPM_ROOT) {
|
||||
for (const packageName of packageNames) {
|
||||
candidates.push(path.join(process.env.GLOBAL_NPM_ROOT, packageName, "src", "index.js"));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const globalRoot = await detectGlobalNpmRoot();
|
||||
for (const packageName of packageNames) {
|
||||
candidates.push(path.join(globalRoot, packageName, "src", "index.js"));
|
||||
}
|
||||
} catch {
|
||||
// ignore detection failure and continue to local fallback
|
||||
}
|
||||
|
||||
candidates.push(path.resolve(__dirname, "..", "..", "packages", "lck-analytics", "src", "index.js"));
|
||||
|
||||
const entryPath = candidates.find((candidate) => fs.existsSync(candidate));
|
||||
if (!entryPath) {
|
||||
throw new Error("Could not find lck-analytics package. Install it globally with `npm install -g lck-analytics` or run from the k-skill repo.");
|
||||
}
|
||||
|
||||
return import(pathToFileURL(entryPath).href);
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function readJson(filePath, fallback = null) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return fallback;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
ensureDir(path.dirname(filePath));
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function readText(filePath, fallback = "") {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return fallback;
|
||||
}
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
if (!token.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
const key = token.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
args[key] = true;
|
||||
continue;
|
||||
}
|
||||
args[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function formatOutput(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function resolveCachePaths(baseDir) {
|
||||
return {
|
||||
root: baseDir,
|
||||
historical: path.join(baseDir, "historical-analysis.json"),
|
||||
live: path.join(baseDir, "live"),
|
||||
reports: path.join(baseDir, "reports"),
|
||||
};
|
||||
}
|
||||
|
||||
async function detectGlobalNpmRoot() {
|
||||
const { execFileSync } = require("node:child_process");
|
||||
return execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureDir,
|
||||
formatOutput,
|
||||
loadLckResults,
|
||||
parseArgs,
|
||||
readJson,
|
||||
readText,
|
||||
resolveCachePaths,
|
||||
writeJson,
|
||||
};
|
||||
52
lck-analytics/scripts/analyze-live-game.js
Executable file
52
lck-analytics/scripts/analyze-live-game.js
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
formatOutput,
|
||||
loadLckResults,
|
||||
parseArgs,
|
||||
readJson,
|
||||
resolveCachePaths,
|
||||
writeJson,
|
||||
} = require("./_lib");
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const gameId = args.game;
|
||||
if (!gameId) {
|
||||
throw new Error("--game <gameId> is required");
|
||||
}
|
||||
|
||||
const cacheDir = path.resolve(args.cache || path.join(process.cwd(), ".openclaw-lck-cache"));
|
||||
const paths = resolveCachePaths(cacheDir);
|
||||
const historicalWrapper = readJson(paths.historical, { data: {} });
|
||||
const pkg = await loadLckResults();
|
||||
|
||||
const liveWindowPayload = args.window ? JSON.parse(fs.readFileSync(path.resolve(args.window), "utf8")) : undefined;
|
||||
const liveDetailsPayload = args.details ? JSON.parse(fs.readFileSync(path.resolve(args.details), "utf8")) : undefined;
|
||||
|
||||
const analysis = await pkg.getGameAnalysis(gameId, {
|
||||
matchId: args.match,
|
||||
number: args.number ? Number(args.number) : null,
|
||||
state: args.state || undefined,
|
||||
historicalDataset: historicalWrapper.data,
|
||||
liveWindowPayload,
|
||||
liveDetailsPayload,
|
||||
});
|
||||
|
||||
const reportFile = path.join(paths.reports, `game-${gameId}.json`);
|
||||
writeJson(reportFile, analysis);
|
||||
|
||||
process.stdout.write(formatOutput({
|
||||
ok: true,
|
||||
reportFile,
|
||||
patch: analysis.patch,
|
||||
turningPoints: analysis.turningPoints,
|
||||
draftEdge: analysis.draft?.overallEdge || null,
|
||||
}));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
44
lck-analytics/scripts/build-match-report.js
Executable file
44
lck-analytics/scripts/build-match-report.js
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env node
|
||||
const path = require("node:path");
|
||||
const {
|
||||
formatOutput,
|
||||
loadLckResults,
|
||||
parseArgs,
|
||||
readJson,
|
||||
resolveCachePaths,
|
||||
writeJson,
|
||||
} = require("./_lib");
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const date = args.date;
|
||||
if (!date) {
|
||||
throw new Error("--date <YYYY-MM-DD> is required");
|
||||
}
|
||||
|
||||
const cacheDir = path.resolve(args.cache || path.join(process.cwd(), ".openclaw-lck-cache"));
|
||||
const paths = resolveCachePaths(cacheDir);
|
||||
const historicalWrapper = readJson(paths.historical, { data: {} });
|
||||
const pkg = await loadLckResults();
|
||||
|
||||
const analysis = await pkg.getMatchAnalysis(date, {
|
||||
team: args.team || undefined,
|
||||
historicalDataset: historicalWrapper.data,
|
||||
});
|
||||
|
||||
const reportFile = path.join(paths.reports, `match-${date}${args.team ? `-${args.team}` : ""}.json`);
|
||||
writeJson(reportFile, analysis);
|
||||
|
||||
process.stdout.write(formatOutput({
|
||||
ok: true,
|
||||
reportFile,
|
||||
queryDate: analysis.queryDate,
|
||||
matchCount: analysis.matches.length,
|
||||
teams: analysis.matches.map((match) => `${match.team1?.name} vs ${match.team2?.name}`),
|
||||
}));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
50
lck-analytics/scripts/sync-oracle.js
Executable file
50
lck-analytics/scripts/sync-oracle.js
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env node
|
||||
const path = require("node:path");
|
||||
const {
|
||||
formatOutput,
|
||||
loadLckResults,
|
||||
parseArgs,
|
||||
readText,
|
||||
resolveCachePaths,
|
||||
writeJson,
|
||||
} = require("./_lib");
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cacheDir = path.resolve(args.cache || path.join(process.cwd(), ".openclaw-lck-cache"));
|
||||
const csvPath = args.csv ? path.resolve(args.csv) : path.join(__dirname, "..", "samples", "oracle-lck-sample.csv");
|
||||
const league = args.league || "LCK";
|
||||
const csvText = readText(csvPath);
|
||||
|
||||
if (!csvText.trim()) {
|
||||
throw new Error(`CSV not found or empty: ${csvPath}`);
|
||||
}
|
||||
|
||||
const pkg = await loadLckResults();
|
||||
const historical = pkg.buildHistoricalAnalytics(csvText, { league });
|
||||
const paths = resolveCachePaths(cacheDir);
|
||||
|
||||
writeJson(paths.historical, {
|
||||
source: {
|
||||
type: "oracle-style-csv",
|
||||
csvPath,
|
||||
league,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
data: historical,
|
||||
});
|
||||
|
||||
process.stdout.write(formatOutput({
|
||||
ok: true,
|
||||
cacheFile: paths.historical,
|
||||
teamRatings: historical.teamPowerRatings.length,
|
||||
matchupStats: historical.matchupStats.length,
|
||||
synergyStats: historical.synergyStats.length,
|
||||
patchMeta: historical.patchMeta.length,
|
||||
}));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -585,10 +585,6 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/coupang-product-search": {
|
||||
"resolved": "packages/coupang-product-search",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"dev": true,
|
||||
|
|
@ -993,6 +989,10 @@
|
|||
"resolved": "packages/kleague-results",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/lck-analytics": {
|
||||
"resolved": "packages/lck-analytics",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"funding": [
|
||||
|
|
@ -1624,6 +1624,7 @@
|
|||
},
|
||||
"packages/coupang-product-search": {
|
||||
"version": "0.1.0",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -1667,6 +1668,13 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/lck-analytics": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/toss-securities": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"lint": "node --check scripts/skill-docs.test.js && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test scripts/skill-docs.test.js && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace used-car-price-search --dry-run",
|
||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace used-car-price-search --dry-run",
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
"release:npm": "changeset publish"
|
||||
|
|
|
|||
151
packages/lck-analytics/README.md
Normal file
151
packages/lck-analytics/README.md
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# lck-analytics
|
||||
|
||||
`jerjangmin`님의 원본 [`lck-analytics` 스킬 팩](https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics)을 k-skill의 npm workspace / Changesets 배포 흐름에 맞춰 옮긴 LCK 전용 Node.js 클라이언트입니다.
|
||||
|
||||
Riot 공식 LoL Esports 데이터와 Oracle's Elixir 스타일 historical row / CSV를 함께 사용해 날짜별 LCK 경기 결과, 현재 순위, live turning point, 밴픽 matchup / synergy, patch meta, 팀 파워 레이팅을 계산합니다.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install lck-analytics
|
||||
```
|
||||
|
||||
글로벌 skill 실행 예시는 아래를 기준으로 합니다.
|
||||
|
||||
```bash
|
||||
npm install -g lck-analytics
|
||||
```
|
||||
|
||||
## Origin / attribution
|
||||
|
||||
- Original skill + prototype package: <https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics>
|
||||
- Original author: `jerjangmin`
|
||||
- k-skill adaptation goal: same capability surface, but released through this repository's official npm/Changesets pipeline
|
||||
|
||||
## Official surfaces
|
||||
|
||||
- 일정/결과: `https://esports-api.lolesports.com/persisted/gw/getSchedule`
|
||||
- 토너먼트 목록: `https://esports-api.lolesports.com/persisted/gw/getTournamentsForLeague`
|
||||
- 순위: `https://esports-api.lolesports.com/persisted/gw/getStandings`
|
||||
- 이벤트 상세: `https://esports-api.lolesports.com/persisted/gw/getEventDetails`
|
||||
- 라이브 window: `https://feed.lolesports.com/livestats/v1/window/{gameId}`
|
||||
- 라이브 details: `https://feed.lolesports.com/livestats/v1/details/{gameId}`
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
const {
|
||||
buildHistoricalAnalytics,
|
||||
getGameAnalysis,
|
||||
getLckSummary,
|
||||
getMatchAnalysis,
|
||||
getMatchResults,
|
||||
getPatchMetaReport,
|
||||
getStandings,
|
||||
getTeamPowerRatings,
|
||||
} = require("lck-analytics");
|
||||
|
||||
(async () => {
|
||||
const results = await getMatchResults("2026-04-01", {
|
||||
team: "한화",
|
||||
});
|
||||
|
||||
const standings = await getStandings({
|
||||
date: "2026-04-01",
|
||||
team: "T1",
|
||||
});
|
||||
|
||||
const summary = await getLckSummary("2026-04-01", {
|
||||
team: "한화",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
const historical = buildHistoricalAnalytics([
|
||||
{
|
||||
league: "LCK",
|
||||
matchid: "sample-1",
|
||||
date: "2026-04-01",
|
||||
patch: "16.6.753.8272",
|
||||
side: "blue",
|
||||
teamname: "Hanwha Life Esports",
|
||||
opponentteam: "T1",
|
||||
playername: "HLE Zeus",
|
||||
position: "top",
|
||||
champion: "Aatrox",
|
||||
opponentchampion: "Gnar",
|
||||
result: "win",
|
||||
gd15: 1200,
|
||||
csd15: 18,
|
||||
xpd15: 340,
|
||||
drg: 100,
|
||||
bn: 100,
|
||||
blindpick: 0,
|
||||
counterpick: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const patchMeta = getPatchMetaReport(historical, "16.6.753.8272");
|
||||
const ratings = getTeamPowerRatings(historical);
|
||||
|
||||
const gameAnalysis = await getGameAnalysis("game-id", {
|
||||
historicalDataset: historical,
|
||||
liveWindowPayload: {/* optional cached payload */},
|
||||
liveDetailsPayload: {/* optional cached payload */},
|
||||
});
|
||||
|
||||
const matchAnalysis = await getMatchAnalysis("2026-04-01", {
|
||||
historicalDataset: historical,
|
||||
});
|
||||
|
||||
console.log(results.matches[0]);
|
||||
console.log(standings.rows[0]);
|
||||
console.log(summary);
|
||||
console.log(patchMeta);
|
||||
console.log(ratings[0]);
|
||||
console.log(gameAnalysis.turningPoints);
|
||||
console.log(matchAnalysis.matches[0]?.powerPreview);
|
||||
})();
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `getMatchResults(date, options)`
|
||||
|
||||
- `date`: `YYYY-MM-DD` 또는 `Date`
|
||||
- `options.team`: 현재명 / 과거명 / 한글 / 영문 / 약칭 alias
|
||||
- `options.maxPages`: 일정 페이지 탐색 상한, 기본값 `6`
|
||||
- 기본적으로 `matches[*].games[*].live` 와 `matches[*].live` 에 인게임 실시간 요약을 채웁니다
|
||||
- `options.includeLiveDetails === false` 이면 상세 live fetch를 생략합니다
|
||||
|
||||
### `getStandings(options)`
|
||||
|
||||
- `options.date`: `YYYY-MM-DD` 또는 `Date`
|
||||
- `options.tournamentId`: 특정 토너먼트 강제 지정 가능
|
||||
- `options.team`: 특정 팀만 현재 순위에서 필터링
|
||||
|
||||
### `getLckSummary(date, options)`
|
||||
|
||||
- 날짜 결과와 해당 시점 스플릿 순위를 한 번에 반환합니다
|
||||
|
||||
### `buildHistoricalAnalytics(input, options)`
|
||||
|
||||
- Oracle's Elixir 스타일 CSV 문자열 또는 row 배열로 historical 분석 데이터셋을 만듭니다
|
||||
- 반환값에는 `teamPowerRatings`, `championStats`, `matchupStats`, `synergyStats`, `patchMeta` 가 포함됩니다
|
||||
|
||||
### `getGameAnalysis(gameId, options)`
|
||||
|
||||
- live window/details payload를 기반으로 timeline, turning points, draft edge, patch meta context를 계산합니다
|
||||
|
||||
### `getMatchAnalysis(date, options)`
|
||||
|
||||
- 날짜별 match 결과 위에 게임별 분석과 팀 파워 preview를 붙여 반환합니다
|
||||
|
||||
## Release note
|
||||
|
||||
이 패키지는 `packages/lck-analytics` workspace로 관리됩니다. `main` 에 머지되면 이 저장소의 Changesets 흐름이 **Version Packages PR** 을 만들고, 그 PR merge 후 npm publish가 실행됩니다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 기본 Riot web header + 공개 API key fallback을 사용하지만, 안정성을 위해 `LOLESPORTS_API_KEY` 환경변수 override도 지원합니다
|
||||
- turning point 분석은 공개 live snapshot 기반 heuristic 입니다
|
||||
- `DN SOOPers`, `DN FREECS`, `광동 프릭스`, `Afreeca Freecs` 같은 리브랜딩 alias를 같은 canonical team으로 정규화합니다
|
||||
33
packages/lck-analytics/package.json
Normal file
33
packages/lck-analytics/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "lck-analytics",
|
||||
"version": "0.1.0",
|
||||
"description": "LCK match analytics and insights powered by Riot LoL Esports data",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/NomaDamas/k-skill.git"
|
||||
},
|
||||
"keywords": [
|
||||
"k-skill",
|
||||
"korea",
|
||||
"lck",
|
||||
"league-of-legends",
|
||||
"esports"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "node --check src/index.js && node --check src/parse.js && node --check src/teams.js && node --check src/oracle.js && node --check src/analytics.js && node --check test/index.test.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"author": "k-skill maintainers (adapted from jerjangmin's original lck-analytics pack)"
|
||||
}
|
||||
332
packages/lck-analytics/src/analytics.js
Normal file
332
packages/lck-analytics/src/analytics.js
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
const { normalizeLiveGameResponse } = require("./parse");
|
||||
|
||||
function normalizeLiveTimeline(windowPayload, detailsPayload, options = {}) {
|
||||
const windowFrames = Array.isArray(windowPayload?.frames) ? windowPayload.frames : [];
|
||||
const detailsFrames = Array.isArray(detailsPayload?.frames) ? detailsPayload.frames : [];
|
||||
const detailsByTimestamp = new Map(detailsFrames.map((frame) => [frame.rfc460Timestamp, frame]));
|
||||
const firstTimestamp = windowFrames[0]?.rfc460Timestamp || detailsFrames[0]?.rfc460Timestamp || null;
|
||||
|
||||
return windowFrames
|
||||
.map((windowFrame) => normalizeTimelineFrame(windowPayload, windowFrame, detailsByTimestamp.get(windowFrame.rfc460Timestamp), {
|
||||
...options,
|
||||
firstTimestamp,
|
||||
}))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTimelineFrame(windowPayload, windowFrame, detailsFrame, options = {}) {
|
||||
const live = normalizeLiveGameResponse({
|
||||
esportsGameId: windowPayload?.esportsGameId,
|
||||
esportsMatchId: windowPayload?.esportsMatchId,
|
||||
gameMetadata: windowPayload?.gameMetadata,
|
||||
frames: [windowFrame],
|
||||
}, {
|
||||
frames: detailsFrame ? [detailsFrame] : [],
|
||||
}, options);
|
||||
|
||||
if (!live) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: windowFrame.rfc460Timestamp,
|
||||
gameState: windowFrame.gameState || null,
|
||||
durationSeconds: options.firstTimestamp
|
||||
? Math.max(0, Math.round((new Date(windowFrame.rfc460Timestamp) - new Date(options.firstTimestamp)) / 1000))
|
||||
: live.durationSeconds,
|
||||
blueTeam: live.blueTeam,
|
||||
redTeam: live.redTeam,
|
||||
goldDiff: live.goldDiff,
|
||||
killDiff: live.killDiff,
|
||||
objectiveScore: computeObjectiveScore(live.blueTeam) - computeObjectiveScore(live.redTeam),
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeTurningPoints(timeline, options = {}) {
|
||||
const thresholdGoldSwing = options.thresholdGoldSwing || 1800;
|
||||
const thresholdObjectiveSwing = options.thresholdObjectiveSwing || 2;
|
||||
const candidates = [];
|
||||
|
||||
for (let index = 1; index < timeline.length; index += 1) {
|
||||
const previous = timeline[index - 1];
|
||||
const current = timeline[index];
|
||||
const goldSwing = Math.abs((current.goldDiff ?? 0) - (previous.goldDiff ?? 0));
|
||||
const killSwing = Math.abs((current.killDiff ?? 0) - (previous.killDiff ?? 0));
|
||||
const objectiveSwing = Math.abs((current.objectiveScore ?? 0) - (previous.objectiveScore ?? 0));
|
||||
const leadFlip = Math.sign(current.goldDiff || 0) !== 0
|
||||
&& Math.sign(previous.goldDiff || 0) !== 0
|
||||
&& Math.sign(current.goldDiff || 0) !== Math.sign(previous.goldDiff || 0);
|
||||
|
||||
if (!leadFlip && goldSwing < thresholdGoldSwing && objectiveSwing < thresholdObjectiveSwing && killSwing < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const favoredSide = resolveFavoredSide(current.goldDiff);
|
||||
candidates.push({
|
||||
timestamp: current.timestamp,
|
||||
durationSeconds: current.durationSeconds,
|
||||
goldSwing,
|
||||
killSwing,
|
||||
objectiveSwing,
|
||||
favoredSide,
|
||||
swingScore: round((goldSwing / 300) + (killSwing * 3) + (objectiveSwing * 6) + (leadFlip ? 10 : 0), 2),
|
||||
reason: summarizeTurningPoint(previous, current, { goldSwing, killSwing, objectiveSwing, leadFlip, favoredSide }),
|
||||
});
|
||||
}
|
||||
|
||||
return candidates.sort((left, right) => right.swingScore - left.swingScore).slice(0, options.limit || 3);
|
||||
}
|
||||
|
||||
function summarizeTurningPoint(previous, current, context) {
|
||||
const pieces = [];
|
||||
if (context.leadFlip) {
|
||||
pieces.push("골드 리드가 뒤집혔습니다");
|
||||
}
|
||||
if (context.goldSwing >= 1800) {
|
||||
pieces.push(`골드 차이가 ${formatSigned(current.goldDiff)}로 크게 움직였습니다`);
|
||||
}
|
||||
if (context.killSwing >= 3) {
|
||||
pieces.push(`교전으로 킬 차이가 ${formatSigned(current.killDiff)}가 됐습니다`);
|
||||
}
|
||||
if (context.objectiveSwing >= 2) {
|
||||
pieces.push("주요 오브젝트 격차가 벌어졌습니다");
|
||||
}
|
||||
if (pieces.length === 0) {
|
||||
pieces.push("중요한 흐름 변화가 감지됐습니다");
|
||||
}
|
||||
|
||||
const side = context.favoredSide === "blue" ? "블루" : context.favoredSide === "red" ? "레드" : null;
|
||||
return side ? `${side} 진영 기준 turning point: ${pieces.join(", ")}` : pieces.join(", ");
|
||||
}
|
||||
|
||||
function analyzeDraft(game, historicalDataset, options = {}) {
|
||||
const patch = options.patch || game?.live?.patchVersion || null;
|
||||
const blueParticipants = game?.live?.blueTeam?.participants || [];
|
||||
const redParticipants = game?.live?.redTeam?.participants || [];
|
||||
const matchupStats = historicalDataset?.matchupStats || [];
|
||||
const synergyStats = historicalDataset?.synergyStats || [];
|
||||
|
||||
const roleMatchups = blueParticipants.map((blueParticipant) => {
|
||||
const redParticipant = redParticipants.find((candidate) => candidate.role === blueParticipant.role);
|
||||
if (!redParticipant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blueEdge = findMatchup(matchupStats, patch, blueParticipant.role, blueParticipant.championId, redParticipant.championId);
|
||||
const redEdge = findMatchup(matchupStats, patch, redParticipant.role, redParticipant.championId, blueParticipant.championId);
|
||||
const favoredSide = pickFavoredSide(blueEdge, redEdge);
|
||||
|
||||
return {
|
||||
role: blueParticipant.role,
|
||||
blueChampion: blueParticipant.championId,
|
||||
redChampion: redParticipant.championId,
|
||||
favoredSide,
|
||||
blueSample: blueEdge?.games || 0,
|
||||
redSample: redEdge?.games || 0,
|
||||
summary: summarizeMatchup(blueEdge, redEdge, blueParticipant, redParticipant),
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
const blueSynergy = scoreSynergy(blueParticipants, synergyStats, patch);
|
||||
const redSynergy = scoreSynergy(redParticipants, synergyStats, patch);
|
||||
|
||||
return {
|
||||
patch,
|
||||
roleMatchups,
|
||||
blueSynergy,
|
||||
redSynergy,
|
||||
overallEdge: pickOverallDraftEdge(roleMatchups, blueSynergy, redSynergy),
|
||||
};
|
||||
}
|
||||
|
||||
function scoreSynergy(participants, synergyStats, patch) {
|
||||
const champions = participants.map((participant) => participant.championId).filter(Boolean);
|
||||
const pairs = [];
|
||||
for (let index = 0; index < champions.length; index += 1) {
|
||||
for (let inner = index + 1; inner < champions.length; inner += 1) {
|
||||
const championA = champions[index];
|
||||
const championB = champions[inner];
|
||||
const stat = synergyStats.find((entry) => (
|
||||
(!patch || entry.patch === patch)
|
||||
&& ((entry.championA === championA && entry.championB === championB)
|
||||
|| (entry.championA === championB && entry.championB === championA))
|
||||
));
|
||||
if (stat) {
|
||||
pairs.push(stat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
samplePairs: pairs.length,
|
||||
avgWinRate: round(average(pairs.map((entry) => entry.winRate)), 2),
|
||||
topPairs: pairs.sort((left, right) => right.games - left.games).slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeMetaForGame(game, historicalDataset, options = {}) {
|
||||
const patch = options.patch || game?.live?.patchVersion || null;
|
||||
const patchMeta = (historicalDataset?.patchMeta || []).find((entry) => !patch || entry.patch === patch) || null;
|
||||
return {
|
||||
patch,
|
||||
topPicks: patchMeta?.topPicks || [],
|
||||
risers: patchMeta?.risers || [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildGameAnalysis(game, historicalDataset, options = {}) {
|
||||
const timeline = normalizeLiveTimeline(options.liveWindowPayload, options.liveDetailsPayload, {
|
||||
gameId: game.id,
|
||||
matchId: options.matchId,
|
||||
});
|
||||
const currentLive = timeline.at(-1) ? {
|
||||
gameId: game.id,
|
||||
patchVersion: options.liveWindowPayload?.gameMetadata?.patchVersion || null,
|
||||
durationSeconds: timeline.at(-1).durationSeconds,
|
||||
blueTeam: timeline.at(-1).blueTeam,
|
||||
redTeam: timeline.at(-1).redTeam,
|
||||
goldDiff: timeline.at(-1).goldDiff,
|
||||
killDiff: timeline.at(-1).killDiff,
|
||||
} : game.live || null;
|
||||
const enrichedGame = {
|
||||
...game,
|
||||
live: currentLive,
|
||||
};
|
||||
|
||||
return {
|
||||
gameId: game.id,
|
||||
number: game.number,
|
||||
state: game.state,
|
||||
patch: currentLive?.patchVersion || null,
|
||||
current: currentLive,
|
||||
timeline,
|
||||
turningPoints: analyzeTurningPoints(timeline, options.turningPointOptions),
|
||||
draft: analyzeDraft(enrichedGame, historicalDataset, {
|
||||
patch: currentLive?.patchVersion || null,
|
||||
}),
|
||||
meta: summarizeMetaForGame(enrichedGame, historicalDataset, {
|
||||
patch: currentLive?.patchVersion || null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTeamPreview(teamId, historicalDataset) {
|
||||
const ratings = historicalDataset?.teamPowerRatings || [];
|
||||
return ratings.find((entry) => entry.teamId === teamId) || null;
|
||||
}
|
||||
|
||||
function compareTeams(teamAId, teamBId, historicalDataset) {
|
||||
const left = buildTeamPreview(teamAId, historicalDataset);
|
||||
const right = buildTeamPreview(teamBId, historicalDataset);
|
||||
return {
|
||||
teamA: left,
|
||||
teamB: right,
|
||||
favoredTeamId: !left || !right ? left?.teamId || right?.teamId || null : left.powerScore >= right.powerScore ? left.teamId : right.teamId,
|
||||
powerGap: left && right ? round(Math.abs(left.powerScore - right.powerScore), 2) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function getPatchMetaSummary(historicalDataset, patch) {
|
||||
return (historicalDataset?.patchMeta || []).find((entry) => entry.patch === patch) || null;
|
||||
}
|
||||
|
||||
function findMatchup(matchupStats, patch, role, champion, opponentChampion) {
|
||||
return matchupStats.find((entry) => (
|
||||
(!patch || entry.patch === patch)
|
||||
&& entry.position === role
|
||||
&& entry.champion === champion
|
||||
&& entry.opponentChampion === opponentChampion
|
||||
)) || null;
|
||||
}
|
||||
|
||||
function pickFavoredSide(blueEdge, redEdge) {
|
||||
const blueScore = scoreMatchup(blueEdge);
|
||||
const redScore = scoreMatchup(redEdge);
|
||||
if (blueScore === redScore) {
|
||||
return null;
|
||||
}
|
||||
return blueScore > redScore ? "blue" : "red";
|
||||
}
|
||||
|
||||
function scoreMatchup(edge) {
|
||||
if (!edge) {
|
||||
return 0;
|
||||
}
|
||||
return (edge.winRate || 0) + Math.min(edge.games || 0, 10);
|
||||
}
|
||||
|
||||
function summarizeMatchup(blueEdge, redEdge, blueParticipant, redParticipant) {
|
||||
if (!blueEdge && !redEdge) {
|
||||
return `${blueParticipant.championId} vs ${redParticipant.championId} 매치업 표본이 부족합니다.`;
|
||||
}
|
||||
|
||||
const favored = pickFavoredSide(blueEdge, redEdge);
|
||||
if (favored === "blue") {
|
||||
return `${blueParticipant.championId} 쪽이 유리한 매치업으로 보입니다.`;
|
||||
}
|
||||
if (favored === "red") {
|
||||
return `${redParticipant.championId} 쪽이 유리한 매치업으로 보입니다.`;
|
||||
}
|
||||
return `${blueParticipant.championId} vs ${redParticipant.championId} 매치업은 팽팽합니다.`;
|
||||
}
|
||||
|
||||
function pickOverallDraftEdge(roleMatchups, blueSynergy, redSynergy) {
|
||||
const blueWins = roleMatchups.filter((entry) => entry.favoredSide === "blue").length;
|
||||
const redWins = roleMatchups.filter((entry) => entry.favoredSide === "red").length;
|
||||
const blueScore = blueWins + ((blueSynergy.avgWinRate || 50) / 100);
|
||||
const redScore = redWins + ((redSynergy.avgWinRate || 50) / 100);
|
||||
if (blueScore === redScore) {
|
||||
return null;
|
||||
}
|
||||
return blueScore > redScore ? "blue" : "red";
|
||||
}
|
||||
|
||||
function computeObjectiveScore(team) {
|
||||
if (!team) {
|
||||
return 0;
|
||||
}
|
||||
return (team.towers || 0)
|
||||
+ ((team.inhibitors || 0) * 1.5)
|
||||
+ ((team.barons || 0) * 3)
|
||||
+ ((team.dragonCount || 0) * 1.2);
|
||||
}
|
||||
|
||||
function resolveFavoredSide(goldDiff) {
|
||||
if (!Number.isFinite(goldDiff) || goldDiff === 0) {
|
||||
return null;
|
||||
}
|
||||
return goldDiff > 0 ? "blue" : "red";
|
||||
}
|
||||
|
||||
function formatSigned(value) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "0";
|
||||
}
|
||||
return `${value > 0 ? "+" : ""}${Math.round(value)}`;
|
||||
}
|
||||
|
||||
function average(values) {
|
||||
const filtered = values.filter((value) => Number.isFinite(value));
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return filtered.reduce((sum, value) => sum + value, 0) / filtered.length;
|
||||
}
|
||||
|
||||
function round(value, digits = 2) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const scale = 10 ** digits;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
analyzeDraft,
|
||||
analyzeTurningPoints,
|
||||
buildGameAnalysis,
|
||||
buildTeamPreview,
|
||||
compareTeams,
|
||||
getPatchMetaSummary,
|
||||
normalizeLiveTimeline,
|
||||
};
|
||||
24
packages/lck-analytics/src/constants.js
Normal file
24
packages/lck-analytics/src/constants.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const LCK_LEAGUE_ID = "98767991310872058";
|
||||
const DEFAULT_LOLESPORTS_API_KEY = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z";
|
||||
const LOLESPORTS_API_BASE_URL = "https://esports-api.lolesports.com/persisted/gw";
|
||||
const LIVE_STATS_BASE_URL = "https://feed.lolesports.com/livestats/v1";
|
||||
const DEFAULT_HEADERS = {
|
||||
accept: "application/json",
|
||||
"accept-language": "en-US,en;q=0.9,ko-KR;q=0.8,ko;q=0.7",
|
||||
"user-agent": "k-skill/lck-analytics",
|
||||
};
|
||||
|
||||
const STATUS_MAP = {
|
||||
completed: { state: "finished", label: "종료", finished: true },
|
||||
inProgress: { state: "live", label: "진행 중", finished: false },
|
||||
unstarted: { state: "scheduled", label: "예정", finished: false },
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_HEADERS,
|
||||
DEFAULT_LOLESPORTS_API_KEY,
|
||||
LCK_LEAGUE_ID,
|
||||
LIVE_STATS_BASE_URL,
|
||||
LOLESPORTS_API_BASE_URL,
|
||||
STATUS_MAP,
|
||||
};
|
||||
461
packages/lck-analytics/src/index.js
Normal file
461
packages/lck-analytics/src/index.js
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
const {
|
||||
DEFAULT_HEADERS,
|
||||
DEFAULT_LOLESPORTS_API_KEY,
|
||||
LCK_LEAGUE_ID,
|
||||
LIVE_STATS_BASE_URL,
|
||||
LOLESPORTS_API_BASE_URL,
|
||||
} = require("./constants");
|
||||
const {
|
||||
normalizeDateInput,
|
||||
normalizeEventDetailsResponse,
|
||||
normalizeLiveGameResponse,
|
||||
normalizeScheduleResponse,
|
||||
normalizeStandingsResponse,
|
||||
normalizeTournamentList,
|
||||
resolveTournamentForDate,
|
||||
} = require("./parse");
|
||||
const {
|
||||
buildGameAnalysis,
|
||||
compareTeams,
|
||||
getPatchMetaSummary,
|
||||
} = require("./analytics");
|
||||
const {
|
||||
buildHistoricalDataset,
|
||||
parseOracleCsv,
|
||||
} = require("./oracle");
|
||||
|
||||
async function requestJson(path, options = {}) {
|
||||
const fetchImpl = options.fetchImpl || global.fetch;
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("A fetch implementation is required.");
|
||||
}
|
||||
|
||||
const url = new URL(`${LOLESPORTS_API_BASE_URL}/${path}`);
|
||||
for (const [key, value] of Object.entries(options.query || {})) {
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
"x-api-key": options.apiKey || process.env.LOLESPORTS_API_KEY || DEFAULT_LOLESPORTS_API_KEY,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`LoL Esports request failed with ${response.status} for ${url}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchSchedulePage(options = {}) {
|
||||
return requestJson("getSchedule", {
|
||||
query: {
|
||||
hl: options.hl || "en-US",
|
||||
leagueId: options.leagueId || LCK_LEAGUE_ID,
|
||||
pageToken: options.pageToken,
|
||||
},
|
||||
fetchImpl: options.fetchImpl,
|
||||
apiKey: options.apiKey,
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchTournaments(options = {}) {
|
||||
return requestJson("getTournamentsForLeague", {
|
||||
query: {
|
||||
hl: options.hl || "en-US",
|
||||
leagueId: options.leagueId || LCK_LEAGUE_ID,
|
||||
},
|
||||
fetchImpl: options.fetchImpl,
|
||||
apiKey: options.apiKey,
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchStandings(options = {}) {
|
||||
return requestJson("getStandings", {
|
||||
query: {
|
||||
hl: options.hl || "en-US",
|
||||
tournamentId: options.tournamentId,
|
||||
},
|
||||
fetchImpl: options.fetchImpl,
|
||||
apiKey: options.apiKey,
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchEventDetails(options = {}) {
|
||||
return requestJson("getEventDetails", {
|
||||
query: {
|
||||
hl: options.hl || "en-US",
|
||||
id: options.eventId,
|
||||
},
|
||||
fetchImpl: options.fetchImpl,
|
||||
apiKey: options.apiKey,
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function requestLiveJson(kind, gameId, options = {}) {
|
||||
const fetchImpl = options.fetchImpl || global.fetch;
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("A fetch implementation is required.");
|
||||
}
|
||||
|
||||
const url = new URL(`${LIVE_STATS_BASE_URL}/${kind}/${gameId}`);
|
||||
const response = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"user-agent": "k-skill/lck-analytics",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`LoL Esports live stats request failed with ${response.status} for ${url}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchLiveWindow(options = {}) {
|
||||
return requestLiveJson("window", options.gameId, options);
|
||||
}
|
||||
|
||||
async function fetchLiveDetails(options = {}) {
|
||||
return requestLiveJson("details", options.gameId, options);
|
||||
}
|
||||
|
||||
async function getMatchResults(date, options = {}) {
|
||||
const requestedDate = normalizeDateInput(date).isoDate;
|
||||
const pages = [];
|
||||
let payload = options.schedulePayload || await fetchSchedulePage(options);
|
||||
pages.push(payload);
|
||||
|
||||
const maxPages = Number(options.maxPages || 6);
|
||||
let direction = resolveSearchDirection(payload, requestedDate);
|
||||
let pageCount = 1;
|
||||
|
||||
while (direction && pageCount < maxPages) {
|
||||
const pageToken = payload?.data?.schedule?.pages?.[direction];
|
||||
if (!pageToken) {
|
||||
break;
|
||||
}
|
||||
|
||||
payload = await fetchSchedulePage({
|
||||
...options,
|
||||
pageToken,
|
||||
});
|
||||
pages.push(payload);
|
||||
pageCount += 1;
|
||||
direction = resolveSearchDirection(payload, requestedDate);
|
||||
}
|
||||
|
||||
const mergedPayload = mergeSchedulePages(pages);
|
||||
const result = normalizeScheduleResponse(mergedPayload, {
|
||||
date: requestedDate,
|
||||
team: options.team,
|
||||
});
|
||||
|
||||
const matches = options.includeLiveDetails === false
|
||||
? result.matches
|
||||
: await enrichMatchesWithLiveDetails(result.matches, options);
|
||||
|
||||
return {
|
||||
...result,
|
||||
matches,
|
||||
pagesExamined: pages.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function getStandings(options = {}) {
|
||||
const tournamentsPayload = options.tournamentsPayload || await fetchTournaments(options);
|
||||
const tournaments = normalizeTournamentList(tournamentsPayload);
|
||||
const tournament = options.tournamentId
|
||||
? tournaments.find((candidate) => candidate.id === String(options.tournamentId)) || { id: String(options.tournamentId) }
|
||||
: resolveTournamentForDate(tournaments, options.date || new Date());
|
||||
|
||||
if (!tournament?.id) {
|
||||
return {
|
||||
tournamentId: null,
|
||||
tournamentName: null,
|
||||
stage: null,
|
||||
sectionName: null,
|
||||
filteredTeam: options.team ? { input: options.team, canonicalId: null, normalized: options.team } : null,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const standingsPayload = options.standingsPayload || await fetchStandings({
|
||||
...options,
|
||||
tournamentId: tournament.id,
|
||||
});
|
||||
|
||||
return normalizeStandingsResponse(standingsPayload, {
|
||||
tournament,
|
||||
team: options.team,
|
||||
});
|
||||
}
|
||||
|
||||
async function enrichMatchesWithLiveDetails(matches, options = {}) {
|
||||
return Promise.all(matches.map(async (match) => {
|
||||
const eventDetailsPayload = options.eventDetailsByMatchId?.[match.eventId]
|
||||
|| options.eventDetailsByMatchId?.[match.matchId]
|
||||
|| await fetchEventDetails({
|
||||
...options,
|
||||
eventId: match.eventId || match.matchId,
|
||||
});
|
||||
|
||||
const eventDetails = normalizeEventDetailsResponse(eventDetailsPayload);
|
||||
const games = await Promise.all(eventDetails.games.map(async (game) => {
|
||||
if (game.state !== "inProgress") {
|
||||
return {
|
||||
...game,
|
||||
live: null,
|
||||
};
|
||||
}
|
||||
|
||||
const liveWindowPayload = options.liveWindowByGameId?.[game.id] !== undefined
|
||||
? options.liveWindowByGameId[game.id]
|
||||
: await fetchLiveWindow({ ...options, gameId: game.id });
|
||||
const liveDetailsPayload = options.liveDetailsByGameId?.[game.id] !== undefined
|
||||
? options.liveDetailsByGameId[game.id]
|
||||
: await fetchLiveDetails({ ...options, gameId: game.id });
|
||||
|
||||
return {
|
||||
...game,
|
||||
live: normalizeLiveGameResponse(liveWindowPayload, liveDetailsPayload, {
|
||||
gameId: game.id,
|
||||
matchId: match.matchId,
|
||||
}),
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
...match,
|
||||
streams: eventDetails.streams,
|
||||
games,
|
||||
live: match.status.state === "live" ? summarizeMatchLive(games) : null,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async function getLckSummary(date, options = {}) {
|
||||
const matches = options.matchesResponse || await getMatchResults(date, options);
|
||||
const summary = {
|
||||
queryDate: matches.queryDate,
|
||||
filteredTeam: matches.filteredTeam,
|
||||
matches: matches.matches,
|
||||
};
|
||||
|
||||
if (options.includeStandings !== false) {
|
||||
summary.standings = await getStandings({
|
||||
...options,
|
||||
date,
|
||||
team: options.team,
|
||||
tournamentsPayload: options.tournamentsPayload,
|
||||
standingsPayload: options.standingsPayload,
|
||||
});
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildHistoricalAnalytics(input, options = {}) {
|
||||
const rows = typeof input === "string" ? parseOracleCsv(input) : input;
|
||||
return buildHistoricalDataset(Array.isArray(rows) ? rows : [], options);
|
||||
}
|
||||
|
||||
async function getGameAnalysis(gameId, options = {}) {
|
||||
const historical = options.historicalDataset
|
||||
|| buildHistoricalAnalytics(options.oracleCsv || options.historicalRows || [], options);
|
||||
const liveWindowPayload = options.liveWindowPayload !== undefined
|
||||
? options.liveWindowPayload
|
||||
: await fetchLiveWindow({ ...options, gameId });
|
||||
const liveDetailsPayload = options.liveDetailsPayload !== undefined
|
||||
? options.liveDetailsPayload
|
||||
: await fetchLiveDetails({ ...options, gameId });
|
||||
|
||||
const game = options.game || {
|
||||
id: gameId,
|
||||
number: options.number || null,
|
||||
state: options.state || (liveWindowPayload?.frames?.length ? "inProgress" : null),
|
||||
live: null,
|
||||
};
|
||||
|
||||
return buildGameAnalysis(game, historical, {
|
||||
matchId: options.matchId,
|
||||
liveWindowPayload,
|
||||
liveDetailsPayload,
|
||||
turningPointOptions: options.turningPointOptions,
|
||||
});
|
||||
}
|
||||
|
||||
async function getMatchAnalysis(date, options = {}) {
|
||||
const matchesResponse = options.matchesResponse || await getMatchResults(date, options);
|
||||
const historical = options.historicalDataset
|
||||
|| buildHistoricalAnalytics(options.oracleCsv || options.historicalRows || [], options);
|
||||
|
||||
const matches = await Promise.all(matchesResponse.matches.map(async (match) => {
|
||||
const baseMatch = options.includeLiveDetails === false || match.games ? match : (await enrichMatchesWithLiveDetails([match], options))[0];
|
||||
const analyses = await Promise.all((baseMatch.games || []).map(async (game) => {
|
||||
if (!game.live && options.liveWindowByGameId?.[game.id] === undefined && game.state !== "inProgress") {
|
||||
return {
|
||||
gameId: game.id,
|
||||
number: game.number,
|
||||
state: game.state,
|
||||
patch: null,
|
||||
current: null,
|
||||
timeline: [],
|
||||
turningPoints: [],
|
||||
draft: null,
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
|
||||
return getGameAnalysis(game.id, {
|
||||
...options,
|
||||
game,
|
||||
matchId: baseMatch.matchId,
|
||||
historicalDataset: historical,
|
||||
liveWindowPayload: options.liveWindowByGameId?.[game.id],
|
||||
liveDetailsPayload: options.liveDetailsByGameId?.[game.id],
|
||||
});
|
||||
}));
|
||||
|
||||
return {
|
||||
...baseMatch,
|
||||
analyses,
|
||||
powerPreview: compareTeams(baseMatch.team1?.canonicalId, baseMatch.team2?.canonicalId, historical),
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
queryDate: matchesResponse.queryDate,
|
||||
filteredTeam: matchesResponse.filteredTeam,
|
||||
matches,
|
||||
};
|
||||
}
|
||||
|
||||
function getTeamPowerRatings(input, options = {}) {
|
||||
const historical = input?.teamPowerRatings ? input : buildHistoricalAnalytics(input, options);
|
||||
return historical.teamPowerRatings;
|
||||
}
|
||||
|
||||
function getPatchMetaReport(input, patch, options = {}) {
|
||||
const historical = input?.patchMeta ? input : buildHistoricalAnalytics(input, options);
|
||||
return getPatchMetaSummary(historical, patch);
|
||||
}
|
||||
|
||||
function mergeSchedulePages(pages) {
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const payload of pages) {
|
||||
for (const event of payload?.data?.schedule?.events || []) {
|
||||
const key = event?.match?.id || event?.id;
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
merged.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
schedule: {
|
||||
events: merged,
|
||||
pages: pages.at(-1)?.data?.schedule?.pages || { older: null, newer: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeMatchLive(games) {
|
||||
const currentGame = games.find((game) => game.state === "inProgress" && game.live)
|
||||
|| games.find((game) => game.live);
|
||||
if (!currentGame) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentGameNumber: currentGame.number,
|
||||
currentGameState: currentGame.state,
|
||||
gameId: currentGame.id,
|
||||
gameState: currentGame.live?.gameState || null,
|
||||
updatedAt: currentGame.live?.updatedAt || null,
|
||||
durationSeconds: currentGame.live?.durationSeconds ?? null,
|
||||
blueTeam: currentGame.live?.blueTeam || null,
|
||||
redTeam: currentGame.live?.redTeam || null,
|
||||
goldDiff: currentGame.live?.goldDiff ?? null,
|
||||
killDiff: currentGame.live?.killDiff ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSearchDirection(payload, requestedDate) {
|
||||
const eventDates = (payload?.data?.schedule?.events || [])
|
||||
.map((event) => event?.startTime)
|
||||
.filter(Boolean)
|
||||
.map((startTime) => new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date(startTime)));
|
||||
|
||||
if (eventDates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sorted = [...eventDates].sort();
|
||||
const minDate = sorted[0];
|
||||
const maxDate = sorted.at(-1);
|
||||
|
||||
if (requestedDate < minDate) {
|
||||
return "older";
|
||||
}
|
||||
|
||||
if (requestedDate > maxDate) {
|
||||
return "newer";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildHistoricalAnalytics,
|
||||
enrichMatchesWithLiveDetails,
|
||||
fetchEventDetails,
|
||||
fetchLiveDetails,
|
||||
fetchLiveWindow,
|
||||
fetchSchedulePage,
|
||||
fetchStandings,
|
||||
fetchTournaments,
|
||||
getGameAnalysis,
|
||||
getLckSummary,
|
||||
getMatchAnalysis,
|
||||
getMatchResults,
|
||||
getPatchMetaReport,
|
||||
getStandings,
|
||||
getTeamPowerRatings,
|
||||
mergeSchedulePages,
|
||||
parseOracleCsv,
|
||||
requestJson,
|
||||
requestLiveJson,
|
||||
resolveSearchDirection,
|
||||
summarizeMatchLive,
|
||||
};
|
||||
425
packages/lck-analytics/src/oracle.js
Normal file
425
packages/lck-analytics/src/oracle.js
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
const { resolveTeamQuery } = require("./teams");
|
||||
|
||||
function parseOracleCsv(csvText) {
|
||||
const text = String(csvText || "").trim();
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = parseCsv(text);
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [headerRow, ...bodyRows] = rows;
|
||||
const headers = headerRow.map((value) => String(value || "").trim());
|
||||
|
||||
return bodyRows
|
||||
.filter((row) => row.some((cell) => String(cell || "").trim() !== ""))
|
||||
.map((row) => Object.fromEntries(headers.map((header, index) => [header, normalizeCell(row[index])] )));
|
||||
}
|
||||
|
||||
function parseCsv(text) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let cell = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
const next = text[index + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && next === '"') {
|
||||
cell += '"';
|
||||
index += 1;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && char === ',') {
|
||||
row.push(cell);
|
||||
cell = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && (char === '\n' || char === '\r')) {
|
||||
if (char === '\r' && next === '\n') {
|
||||
index += 1;
|
||||
}
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
cell += char;
|
||||
}
|
||||
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function normalizeCell(value) {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric)) {
|
||||
return numeric;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeOracleGameRows(rows, options = {}) {
|
||||
const filteredLeague = options.league || "LCK";
|
||||
const normalized = rows
|
||||
.filter((row) => !filteredLeague || String(row.league || row.League || "").toUpperCase() === String(filteredLeague).toUpperCase())
|
||||
.map((row) => {
|
||||
const teamQuery = resolveTeamQuery(row.teamname || row.Team || row.team || row.team_name);
|
||||
const opponentQuery = resolveTeamQuery(row.opponentteam || row.opponent || row.Opponent || row.opponent_team);
|
||||
const patch = String(row.patch || row.gameversion || row.gameVersion || "");
|
||||
const champion = row.champion || row.Champion || null;
|
||||
const position = String(row.position || row.Pos || row.pos || "").toLowerCase() || null;
|
||||
const result = normalizeResult(row.result ?? row.Result);
|
||||
|
||||
return {
|
||||
matchId: String(row.matchid || row.MatchId || row.match_id || row.gameid || row.GameId || `${row.date || row.Date}-${teamQuery.currentName}-${champion}`),
|
||||
date: String(row.date || row.Date || ""),
|
||||
patch,
|
||||
side: normalizeSide(row.side || row.Side),
|
||||
team: {
|
||||
input: teamQuery.input,
|
||||
canonicalId: teamQuery.canonicalId,
|
||||
name: teamQuery.currentName,
|
||||
},
|
||||
opponent: {
|
||||
input: opponentQuery.input,
|
||||
canonicalId: opponentQuery.canonicalId,
|
||||
name: opponentQuery.currentName,
|
||||
},
|
||||
playerName: row.playername || row.Player || row.player || null,
|
||||
position,
|
||||
champion,
|
||||
opponentChampion: row.opponentchampion || row.opponent_champion || row.OpponentChampion || null,
|
||||
result,
|
||||
goldDiffAt15: toNumber(row.gd15 ?? row.GD15 ?? row.goldDiffAt15),
|
||||
csDiffAt15: toNumber(row.csd15 ?? row.CSD15 ?? row.csDiffAt15),
|
||||
xpDiffAt15: toNumber(row.xpd15 ?? row.XPD15 ?? row.xpDiffAt15),
|
||||
firstBloodRate: toNumber(row.fb ?? row["FB%"] ?? row.firstBloodRate),
|
||||
dragonControlRate: toNumber(row.drg ?? row["DRG%"] ?? row.dragonControlRate),
|
||||
baronControlRate: toNumber(row.bn ?? row["BN%"] ?? row.baronControlRate),
|
||||
towerControlRate: toNumber(row.ft ?? row["FT%"] ?? row.towerControlRate),
|
||||
kills: toNumber(row.kills ?? row.K),
|
||||
deaths: toNumber(row.deaths ?? row.D),
|
||||
assists: toNumber(row.assists ?? row.A),
|
||||
gamesPlayed: toNumber(row.gamesplayed ?? row.GP) ?? 1,
|
||||
blindPick: toBooleanLike(row.blindpick ?? row["BLND%"] ?? row.blindPick),
|
||||
counterPick: toBooleanLike(row.counterpick ?? row["CTR%"] ?? row.counterPick),
|
||||
};
|
||||
})
|
||||
.filter((row) => row.team.canonicalId && row.opponent.canonicalId && row.position && row.champion);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildHistoricalDataset(rows, options = {}) {
|
||||
const normalizedRows = normalizeOracleGameRows(rows, options);
|
||||
return {
|
||||
rows: normalizedRows,
|
||||
teamPowerRatings: buildTeamPowerRatings(normalizedRows),
|
||||
championStats: buildChampionPatchStats(normalizedRows),
|
||||
matchupStats: buildChampionMatchups(normalizedRows),
|
||||
synergyStats: buildChampionSynergies(normalizedRows),
|
||||
patchMeta: buildPatchMetaSummaries(normalizedRows),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTeamPowerRatings(rows) {
|
||||
const grouped = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.team.canonicalId;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, []);
|
||||
}
|
||||
grouped.get(key).push(row);
|
||||
}
|
||||
|
||||
return [...grouped.entries()].map(([teamId, teamRows]) => {
|
||||
const wins = teamRows.filter((row) => row.result === "win").length;
|
||||
const losses = teamRows.filter((row) => row.result === "loss").length;
|
||||
const games = teamRows.length;
|
||||
const recentRows = teamRows.slice(-5);
|
||||
const recentWins = recentRows.filter((row) => row.result === "win").length;
|
||||
const avgGold15 = average(teamRows.map((row) => row.goldDiffAt15));
|
||||
const avgDragon = average(teamRows.map((row) => row.dragonControlRate));
|
||||
const avgBaron = average(teamRows.map((row) => row.baronControlRate));
|
||||
const weightedScore = round(
|
||||
((wins / Math.max(games, 1)) * 60)
|
||||
+ (normalizeScale(avgGold15, -3000, 3000) * 20)
|
||||
+ (normalizeScale(avgDragon, 0, 100) * 10)
|
||||
+ (normalizeScale(avgBaron, 0, 100) * 10),
|
||||
2,
|
||||
);
|
||||
|
||||
return {
|
||||
teamId,
|
||||
games,
|
||||
wins,
|
||||
losses,
|
||||
recentWins,
|
||||
recentGames: recentRows.length,
|
||||
avgGoldDiffAt15: avgGold15,
|
||||
avgDragonControlRate: avgDragon,
|
||||
avgBaronControlRate: avgBaron,
|
||||
powerScore: weightedScore,
|
||||
tier: weightedScore >= 75 ? "elite" : weightedScore >= 60 ? "strong" : weightedScore >= 45 ? "average" : "developing",
|
||||
};
|
||||
}).sort((left, right) => right.powerScore - left.powerScore);
|
||||
}
|
||||
|
||||
function buildChampionPatchStats(rows) {
|
||||
const grouped = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `${row.patch}::${row.position}::${row.champion}`;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, []);
|
||||
}
|
||||
grouped.get(key).push(row);
|
||||
}
|
||||
|
||||
return [...grouped.entries()].map(([key, championRows]) => {
|
||||
const [patch, position, champion] = key.split("::");
|
||||
return {
|
||||
patch,
|
||||
position,
|
||||
champion,
|
||||
games: championRows.length,
|
||||
wins: championRows.filter((row) => row.result === "win").length,
|
||||
winRate: round(rate(championRows.filter((row) => row.result === "win").length, championRows.length), 2),
|
||||
avgGoldDiffAt15: average(championRows.map((row) => row.goldDiffAt15)),
|
||||
avgCsDiffAt15: average(championRows.map((row) => row.csDiffAt15)),
|
||||
avgXpDiffAt15: average(championRows.map((row) => row.xpDiffAt15)),
|
||||
blindPickRate: round(rate(championRows.filter((row) => row.blindPick === true).length, championRows.length), 2),
|
||||
counterPickRate: round(rate(championRows.filter((row) => row.counterPick === true).length, championRows.length), 2),
|
||||
};
|
||||
}).sort((left, right) => {
|
||||
if (left.patch !== right.patch) {
|
||||
return left.patch.localeCompare(right.patch);
|
||||
}
|
||||
return right.games - left.games;
|
||||
});
|
||||
}
|
||||
|
||||
function buildChampionMatchups(rows) {
|
||||
const grouped = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.opponentChampion) {
|
||||
continue;
|
||||
}
|
||||
const key = `${row.patch}::${row.position}::${row.champion}::${row.opponentChampion}`;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, []);
|
||||
}
|
||||
grouped.get(key).push(row);
|
||||
}
|
||||
|
||||
return [...grouped.entries()].map(([key, matchupRows]) => {
|
||||
const [patch, position, champion, opponentChampion] = key.split("::");
|
||||
const wins = matchupRows.filter((row) => row.result === "win").length;
|
||||
return {
|
||||
patch,
|
||||
position,
|
||||
champion,
|
||||
opponentChampion,
|
||||
games: matchupRows.length,
|
||||
wins,
|
||||
winRate: round(rate(wins, matchupRows.length), 2),
|
||||
avgGoldDiffAt15: average(matchupRows.map((row) => row.goldDiffAt15)),
|
||||
avgCsDiffAt15: average(matchupRows.map((row) => row.csDiffAt15)),
|
||||
counterPickRate: round(rate(matchupRows.filter((row) => row.counterPick === true).length, matchupRows.length), 2),
|
||||
};
|
||||
}).sort((left, right) => right.games - left.games);
|
||||
}
|
||||
|
||||
function buildChampionSynergies(rows) {
|
||||
const games = new Map();
|
||||
for (const row of rows) {
|
||||
const key = `${row.matchId}::${row.team.canonicalId}`;
|
||||
if (!games.has(key)) {
|
||||
games.set(key, []);
|
||||
}
|
||||
games.get(key).push(row);
|
||||
}
|
||||
|
||||
const synergy = new Map();
|
||||
for (const gameRows of games.values()) {
|
||||
for (let index = 0; index < gameRows.length; index += 1) {
|
||||
for (let inner = index + 1; inner < gameRows.length; inner += 1) {
|
||||
const left = gameRows[index];
|
||||
const right = gameRows[inner];
|
||||
const pair = [left.champion, right.champion].sort();
|
||||
const key = `${left.patch}::${pair[0]}::${pair[1]}`;
|
||||
if (!synergy.has(key)) {
|
||||
synergy.set(key, []);
|
||||
}
|
||||
synergy.get(key).push(left.result === "win" ? 1 : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...synergy.entries()].map(([key, results]) => {
|
||||
const [patch, championA, championB] = key.split("::");
|
||||
const wins = results.reduce((sum, value) => sum + value, 0);
|
||||
return {
|
||||
patch,
|
||||
championA,
|
||||
championB,
|
||||
games: results.length,
|
||||
wins,
|
||||
winRate: round(rate(wins, results.length), 2),
|
||||
};
|
||||
}).sort((left, right) => right.games - left.games);
|
||||
}
|
||||
|
||||
function buildPatchMetaSummaries(rows) {
|
||||
const championStats = buildChampionPatchStats(rows);
|
||||
const byPatch = new Map();
|
||||
|
||||
for (const stat of championStats) {
|
||||
if (!byPatch.has(stat.patch)) {
|
||||
byPatch.set(stat.patch, []);
|
||||
}
|
||||
byPatch.get(stat.patch).push(stat);
|
||||
}
|
||||
|
||||
return [...byPatch.entries()].map(([patch, stats], index, all) => {
|
||||
const sorted = [...stats].sort((left, right) => right.games - left.games);
|
||||
const topPicks = sorted.slice(0, 5).map((entry) => ({
|
||||
champion: entry.champion,
|
||||
position: entry.position,
|
||||
games: entry.games,
|
||||
winRate: entry.winRate,
|
||||
}));
|
||||
|
||||
const previousPatch = all[index - 1]?.[0] || null;
|
||||
const previousStats = previousPatch ? byPatch.get(previousPatch) || [] : [];
|
||||
const risers = topPicks.map((pick) => {
|
||||
const previous = previousStats.find((entry) => entry.champion === pick.champion && entry.position === pick.position);
|
||||
return {
|
||||
...pick,
|
||||
gameDelta: pick.games - (previous?.games || 0),
|
||||
};
|
||||
}).sort((left, right) => right.gameDelta - left.gameDelta);
|
||||
|
||||
return {
|
||||
patch,
|
||||
topPicks,
|
||||
risers: risers.slice(0, 3),
|
||||
};
|
||||
}).sort((left, right) => left.patch.localeCompare(right.patch));
|
||||
}
|
||||
|
||||
function normalizeResult(value) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (["1", "win", "w", "true"].includes(normalized)) {
|
||||
return "win";
|
||||
}
|
||||
if (["0", "loss", "lose", "l", "false"].includes(normalized)) {
|
||||
return "loss";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSide(value) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (["blue", "b", "100"].includes(normalized)) {
|
||||
return "blue";
|
||||
}
|
||||
if (["red", "r", "200"].includes(normalized)) {
|
||||
return "red";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function toBooleanLike(value) {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
const numeric = toNumber(value);
|
||||
if (numeric === null) {
|
||||
return null;
|
||||
}
|
||||
if (numeric === 0) {
|
||||
return false;
|
||||
}
|
||||
if (numeric === 1) {
|
||||
return true;
|
||||
}
|
||||
return numeric >= 50;
|
||||
}
|
||||
|
||||
function average(values) {
|
||||
const filtered = values.filter((value) => Number.isFinite(value));
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return round(filtered.reduce((sum, value) => sum + value, 0) / filtered.length, 2);
|
||||
}
|
||||
|
||||
function rate(numerator, denominator) {
|
||||
if (!denominator) {
|
||||
return 0;
|
||||
}
|
||||
return (numerator / denominator) * 100;
|
||||
}
|
||||
|
||||
function normalizeScale(value, min, max) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
if (max <= min) {
|
||||
return 0;
|
||||
}
|
||||
const clamped = Math.min(max, Math.max(min, value));
|
||||
return (clamped - min) / (max - min);
|
||||
}
|
||||
|
||||
function round(value, digits = 2) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const scale = 10 ** digits;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildChampionMatchups,
|
||||
buildChampionPatchStats,
|
||||
buildChampionSynergies,
|
||||
buildHistoricalDataset,
|
||||
buildPatchMetaSummaries,
|
||||
buildTeamPowerRatings,
|
||||
normalizeOracleGameRows,
|
||||
parseOracleCsv,
|
||||
};
|
||||
444
packages/lck-analytics/src/parse.js
Normal file
444
packages/lck-analytics/src/parse.js
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
const { STATUS_MAP } = require("./constants");
|
||||
const {
|
||||
resolveTeamPayload,
|
||||
resolveTeamQuery,
|
||||
stripAliasTokens,
|
||||
} = require("./teams");
|
||||
|
||||
function normalizeDateInput(value) {
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const parts = formatter.formatToParts(value).reduce((acc, part) => {
|
||||
if (part.type !== "literal") {
|
||||
acc[part.type] = part.value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
year: parts.year,
|
||||
month: parts.month,
|
||||
day: parts.day,
|
||||
isoDate: `${parts.year}-${parts.month}-${parts.day}`,
|
||||
};
|
||||
}
|
||||
|
||||
const match = String(value || "").trim().match(/^(\d{4})[-.](\d{2})[-.](\d{2})$/);
|
||||
if (!match) {
|
||||
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
|
||||
}
|
||||
|
||||
const [year, month, day] = [match[1], match[2], match[3]];
|
||||
const candidate = new Date(`${year}-${month}-${day}T00:00:00+09:00`);
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
|
||||
}
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
isoDate: `${year}-${month}-${day}`,
|
||||
};
|
||||
}
|
||||
|
||||
function eventToKoreaDateTime(startTime) {
|
||||
const date = new Date(startTime);
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(date).reduce((acc, part) => {
|
||||
if (part.type !== "literal") {
|
||||
acc[part.type] = part.value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
date: `${parts.year}-${parts.month}-${parts.day}`,
|
||||
kickOff: `${parts.hour}:${parts.minute}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMatchStatus(state) {
|
||||
const mapped = STATUS_MAP[state] || {
|
||||
state: String(state || "unknown"),
|
||||
label: String(state || "알 수 없음"),
|
||||
finished: false,
|
||||
};
|
||||
|
||||
return {
|
||||
code: state || "unknown",
|
||||
state: mapped.state,
|
||||
label: mapped.label,
|
||||
finished: mapped.finished,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScheduleResponse(payload, options = {}) {
|
||||
const schedule = payload?.data?.schedule || payload?.schedule || payload || {};
|
||||
const requestedDate = normalizeDateInput(options.date);
|
||||
const requestedTeam = options.team ? resolveTeamQuery(options.team) : null;
|
||||
const events = Array.isArray(schedule.events) ? schedule.events : [];
|
||||
|
||||
const matches = events
|
||||
.filter((event) => event?.league?.slug === "lck")
|
||||
.map(normalizeEventMatch)
|
||||
.filter((match) => match.date === requestedDate.isoDate)
|
||||
.filter((match) => !requestedTeam || matchIncludesRequestedTeam(match, requestedTeam))
|
||||
.sort(compareMatches);
|
||||
|
||||
return {
|
||||
queryDate: requestedDate.isoDate,
|
||||
filteredTeam: requestedTeam
|
||||
? {
|
||||
input: requestedTeam.input,
|
||||
canonicalId: requestedTeam.canonicalId,
|
||||
normalized: requestedTeam.currentName,
|
||||
}
|
||||
: null,
|
||||
matches,
|
||||
pageInfo: {
|
||||
older: schedule.pages?.older || null,
|
||||
newer: schedule.pages?.newer || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEventMatch(event) {
|
||||
const teams = Array.isArray(event?.match?.teams) ? event.match.teams.map(resolveTeamPayload) : [];
|
||||
const [team1, team2] = teams;
|
||||
const koreaTime = eventToKoreaDateTime(event.startTime);
|
||||
const status = normalizeMatchStatus(event.state || event?.match?.state);
|
||||
const score = {
|
||||
team1: normalizeNumber(team1?.result?.gameWins ?? event?.match?.teams?.[0]?.result?.gameWins),
|
||||
team2: normalizeNumber(team2?.result?.gameWins ?? event?.match?.teams?.[1]?.result?.gameWins),
|
||||
};
|
||||
|
||||
return {
|
||||
matchId: event?.match?.id || event?.id || null,
|
||||
eventId: event?.id || null,
|
||||
league: event?.league?.name || "LCK",
|
||||
leagueSlug: event?.league?.slug || "lck",
|
||||
tournamentId: event?.tournament?.id || null,
|
||||
tournamentName: event?.tournament?.name || null,
|
||||
blockName: event?.blockName || null,
|
||||
date: koreaTime.date,
|
||||
kickOff: koreaTime.kickOff,
|
||||
startTime: event?.startTime || null,
|
||||
status,
|
||||
strategy: event?.match?.strategy || null,
|
||||
team1: team1 ? stripAliasTokens(team1) : null,
|
||||
team2: team2 ? stripAliasTokens(team2) : null,
|
||||
score,
|
||||
winner: determineWinner(team1, team2, score, status),
|
||||
flags: Array.isArray(event?.match?.flags) ? event.match.flags : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStandingsResponse(payload, options = {}) {
|
||||
const standings = Array.isArray(payload?.data?.standings) ? payload.data.standings : [];
|
||||
const requestedTeam = options.team ? resolveTeamQuery(options.team) : null;
|
||||
const tournament = options.tournament || null;
|
||||
const stage = standings.flatMap((entry) => entry?.stages || []).find((candidate) => Array.isArray(candidate?.sections));
|
||||
const section = stage?.sections?.find((candidate) => Array.isArray(candidate?.rankings)) || null;
|
||||
const rows = [];
|
||||
|
||||
for (const ranking of section?.rankings || []) {
|
||||
for (const team of ranking.teams || []) {
|
||||
const resolved = resolveTeamPayload(team);
|
||||
const row = {
|
||||
rank: normalizeNumber(ranking.ordinal),
|
||||
team: stripAliasTokens(resolved),
|
||||
wins: normalizeNumber(team?.record?.wins) ?? 0,
|
||||
losses: normalizeNumber(team?.record?.losses) ?? 0,
|
||||
};
|
||||
|
||||
if (!requestedTeam || teamMatchesRequestedTeam(resolved, requestedTeam)) {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort((left, right) => {
|
||||
if (left.rank !== right.rank) {
|
||||
return left.rank - right.rank;
|
||||
}
|
||||
return left.team.name.localeCompare(right.team.name, "en");
|
||||
});
|
||||
|
||||
return {
|
||||
tournamentId: tournament?.id || null,
|
||||
tournamentName: tournament?.slug || tournament?.name || null,
|
||||
stage: stage
|
||||
? {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
slug: stage.slug,
|
||||
}
|
||||
: null,
|
||||
sectionName: section?.name || null,
|
||||
filteredTeam: requestedTeam
|
||||
? {
|
||||
input: requestedTeam.input,
|
||||
canonicalId: requestedTeam.canonicalId,
|
||||
normalized: requestedTeam.currentName,
|
||||
}
|
||||
: null,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTournamentList(payload) {
|
||||
const tournaments = payload?.data?.leagues?.flatMap((league) => league?.tournaments || []) || [];
|
||||
|
||||
return tournaments
|
||||
.map((tournament) => ({
|
||||
id: tournament.id,
|
||||
slug: tournament.slug,
|
||||
startDate: tournament.startDate,
|
||||
endDate: tournament.endDate,
|
||||
}))
|
||||
.sort((left, right) => left.startDate.localeCompare(right.startDate));
|
||||
}
|
||||
|
||||
function normalizeEventDetailsResponse(payload) {
|
||||
const event = payload?.data?.event || payload?.event || {};
|
||||
const matchTeams = Array.isArray(event?.match?.teams) ? event.match.teams.map(resolveTeamPayload) : [];
|
||||
const byId = new Map(matchTeams.map((team) => [String(team.id), team]));
|
||||
|
||||
return {
|
||||
eventId: event?.id || null,
|
||||
streams: Array.isArray(event?.streams) ? event.streams : [],
|
||||
games: (event?.match?.games || []).map((game) => ({
|
||||
id: game?.id || null,
|
||||
number: normalizeNumber(game?.number),
|
||||
state: game?.state || null,
|
||||
teams: (game?.teams || []).map((team) => {
|
||||
const resolved = byId.get(String(team?.id)) || resolveTeamPayload(team);
|
||||
return {
|
||||
side: team?.side || null,
|
||||
team: stripAliasTokens(resolved),
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLiveGameResponse(windowPayload, detailsPayload, options = {}) {
|
||||
const gameMetadata = windowPayload?.gameMetadata || {};
|
||||
const windowFrames = Array.isArray(windowPayload?.frames) ? windowPayload.frames : [];
|
||||
const detailsFrames = Array.isArray(detailsPayload?.frames) ? detailsPayload.frames : [];
|
||||
const latestWindow = windowFrames.at(-1) || null;
|
||||
const latestDetails = detailsFrames.at(-1) || null;
|
||||
|
||||
if (!latestWindow && !latestDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const participantDirectory = buildParticipantDirectory(gameMetadata, latestWindow, latestDetails);
|
||||
const blue = normalizeLiveSide("blue", latestWindow?.blueTeam, participantDirectory);
|
||||
const red = normalizeLiveSide("red", latestWindow?.redTeam, participantDirectory);
|
||||
const firstTimestamp = windowFrames[0]?.rfc460Timestamp || detailsFrames[0]?.rfc460Timestamp || null;
|
||||
const lastTimestamp = latestWindow?.rfc460Timestamp || latestDetails?.rfc460Timestamp || null;
|
||||
const normalized = {
|
||||
gameId: options.gameId || windowPayload?.esportsGameId || null,
|
||||
matchId: windowPayload?.esportsMatchId || options.matchId || null,
|
||||
patchVersion: gameMetadata.patchVersion || null,
|
||||
gameState: latestWindow?.gameState || null,
|
||||
updatedAt: lastTimestamp,
|
||||
durationSeconds: firstTimestamp && lastTimestamp
|
||||
? Math.max(0, Math.round((new Date(lastTimestamp) - new Date(firstTimestamp)) / 1000))
|
||||
: null,
|
||||
blueTeam: blue,
|
||||
redTeam: red,
|
||||
goldDiff: blue.totalGold !== null && red.totalGold !== null ? blue.totalGold - red.totalGold : null,
|
||||
killDiff: blue.totalKills !== null && red.totalKills !== null ? blue.totalKills - red.totalKills : null,
|
||||
};
|
||||
|
||||
return hasMeaningfulLiveStats(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function hasMeaningfulLiveStats(live) {
|
||||
if (!live) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (live.gameState && live.gameState !== "in_game") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamSignals = [live.blueTeam, live.redTeam].some((team) => {
|
||||
if (!team) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (team.totalGold ?? 0) > 0
|
||||
|| (team.totalKills ?? 0) > 0
|
||||
|| (team.towers ?? 0) > 0
|
||||
|| (team.barons ?? 0) > 0
|
||||
|| (team.dragonCount ?? 0) > 0
|
||||
|| team.participants.some((participant) =>
|
||||
(participant.level ?? 0) > 1
|
||||
|| (participant.creepScore ?? 0) > 0
|
||||
|| (participant.totalGold ?? 0) > 0
|
||||
|| (participant.kills ?? 0) > 0
|
||||
|| (participant.deaths ?? 0) > 0
|
||||
|| (participant.assists ?? 0) > 0
|
||||
|| participant.items.length > 0,
|
||||
);
|
||||
});
|
||||
|
||||
return teamSignals;
|
||||
}
|
||||
|
||||
function buildParticipantDirectory(gameMetadata, latestWindow, latestDetails) {
|
||||
const directory = new Map();
|
||||
const sideMaps = [
|
||||
["blue", gameMetadata?.blueTeamMetadata?.participantMetadata || [], latestWindow?.blueTeam?.participants || []],
|
||||
["red", gameMetadata?.redTeamMetadata?.participantMetadata || [], latestWindow?.redTeam?.participants || []],
|
||||
];
|
||||
const detailParticipants = new Map((latestDetails?.participants || []).map((participant) => [participant.participantId, participant]));
|
||||
|
||||
for (const [side, metadataRows, windowRows] of sideMaps) {
|
||||
const windowParticipants = new Map(windowRows.map((participant) => [participant.participantId, participant]));
|
||||
|
||||
for (const metadata of metadataRows) {
|
||||
directory.set(metadata.participantId, {
|
||||
side,
|
||||
participantId: metadata.participantId,
|
||||
esportsPlayerId: metadata.esportsPlayerId || null,
|
||||
summonerName: metadata.summonerName || null,
|
||||
championId: metadata.championId || null,
|
||||
role: metadata.role || null,
|
||||
window: windowParticipants.get(metadata.participantId) || null,
|
||||
details: detailParticipants.get(metadata.participantId) || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
function normalizeLiveSide(side, teamSnapshot, participantDirectory) {
|
||||
const participants = [...participantDirectory.values()]
|
||||
.filter((participant) => participant.side === side)
|
||||
.sort((left, right) => left.participantId - right.participantId)
|
||||
.map((participant) => ({
|
||||
participantId: participant.participantId,
|
||||
esportsPlayerId: participant.esportsPlayerId,
|
||||
summonerName: participant.summonerName,
|
||||
championId: participant.championId,
|
||||
role: participant.role,
|
||||
level: normalizeNumber(participant.window?.level ?? participant.details?.level),
|
||||
kills: normalizeNumber(participant.window?.kills ?? participant.details?.kills),
|
||||
deaths: normalizeNumber(participant.window?.deaths ?? participant.details?.deaths),
|
||||
assists: normalizeNumber(participant.window?.assists ?? participant.details?.assists),
|
||||
creepScore: normalizeNumber(participant.window?.creepScore ?? participant.details?.creepScore),
|
||||
totalGold: normalizeNumber(participant.window?.totalGold ?? participant.details?.totalGoldEarned),
|
||||
items: Array.isArray(participant.details?.items) ? participant.details.items : [],
|
||||
}));
|
||||
|
||||
return {
|
||||
side,
|
||||
totalGold: normalizeNumber(teamSnapshot?.totalGold),
|
||||
totalKills: normalizeNumber(teamSnapshot?.totalKills),
|
||||
towers: normalizeNumber(teamSnapshot?.towers),
|
||||
inhibitors: normalizeNumber(teamSnapshot?.inhibitors),
|
||||
barons: normalizeNumber(teamSnapshot?.barons),
|
||||
dragons: Array.isArray(teamSnapshot?.dragons) ? teamSnapshot.dragons : [],
|
||||
dragonCount: Array.isArray(teamSnapshot?.dragons) ? teamSnapshot.dragons.length : 0,
|
||||
participants,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTournamentForDate(tournaments, date) {
|
||||
const requestedDate = normalizeDateInput(date).isoDate;
|
||||
const direct = tournaments.find((tournament) => tournament.startDate <= requestedDate && requestedDate <= tournament.endDate);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const older = tournaments.filter((tournament) => tournament.startDate <= requestedDate);
|
||||
return older.at(-1) || tournaments[0] || null;
|
||||
}
|
||||
|
||||
function matchIncludesRequestedTeam(match, requestedTeam) {
|
||||
return teamMatchesRequestedTeam(match.team1, requestedTeam) || teamMatchesRequestedTeam(match.team2, requestedTeam);
|
||||
}
|
||||
|
||||
function teamMatchesRequestedTeam(team, requestedTeam) {
|
||||
if (!team) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolved = resolveTeamPayload(team);
|
||||
if (requestedTeam.canonicalId && resolved.canonicalId) {
|
||||
return requestedTeam.canonicalId === resolved.canonicalId;
|
||||
}
|
||||
|
||||
return resolved.aliasTokens.has(requestedTeam.token);
|
||||
}
|
||||
|
||||
function determineWinner(team1, team2, score, status) {
|
||||
if (!status.finished || score.team1 === null || score.team2 === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (score.team1 === score.team2) {
|
||||
return "draw";
|
||||
}
|
||||
|
||||
return score.team1 > score.team2 ? team1?.canonicalId || team1?.code || "team1" : team2?.canonicalId || team2?.code || "team2";
|
||||
}
|
||||
|
||||
function compareMatches(left, right) {
|
||||
const leftKey = `${left.date}T${left.kickOff}`;
|
||||
const rightKey = `${right.date}T${right.kickOff}`;
|
||||
|
||||
if (leftKey !== rightKey) {
|
||||
return leftKey.localeCompare(rightKey);
|
||||
}
|
||||
|
||||
return String(left.matchId || "").localeCompare(String(right.matchId || ""));
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildParticipantDirectory,
|
||||
compareMatches,
|
||||
eventToKoreaDateTime,
|
||||
normalizeDateInput,
|
||||
hasMeaningfulLiveStats,
|
||||
normalizeEventDetailsResponse,
|
||||
normalizeEventMatch,
|
||||
normalizeLiveGameResponse,
|
||||
normalizeLiveSide,
|
||||
normalizeMatchStatus,
|
||||
normalizeScheduleResponse,
|
||||
normalizeStandingsResponse,
|
||||
normalizeTournamentList,
|
||||
resolveTournamentForDate,
|
||||
};
|
||||
189
packages/lck-analytics/src/teams.js
Normal file
189
packages/lck-analytics/src/teams.js
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
function normalizeToken(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFKC")
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z가-힣]+/g, "");
|
||||
}
|
||||
|
||||
const TEAM_REGISTRY = [
|
||||
{
|
||||
canonicalId: "hle",
|
||||
teamIds: ["100205573496804586"],
|
||||
currentName: "Hanwha Life Esports",
|
||||
aliases: ["Hanwha Life Esports", "Hanwha", "HLE", "한화", "한화생명", "한화생명e스포츠"],
|
||||
},
|
||||
{
|
||||
canonicalId: "gen",
|
||||
teamIds: ["100205573495116443"],
|
||||
currentName: "Gen.G Esports",
|
||||
aliases: ["Gen.G", "Gen.G Esports", "GEN", "젠지", "젠지 e스포츠"],
|
||||
},
|
||||
{
|
||||
canonicalId: "t1",
|
||||
teamIds: ["98767991853197861"],
|
||||
currentName: "T1",
|
||||
aliases: ["T1", "SKT", "SKT T1", "SK Telecom T1", "SK텔레콤 T1"],
|
||||
},
|
||||
{
|
||||
canonicalId: "dk",
|
||||
teamIds: ["100725845018863243"],
|
||||
currentName: "Dplus KIA",
|
||||
aliases: ["Dplus KIA", "DK", "Damwon KIA", "DWG KIA", "DAMWON Gaming", "담원", "담원 기아", "디플러스 기아"],
|
||||
},
|
||||
{
|
||||
canonicalId: "kt",
|
||||
teamIds: ["99566404579461230"],
|
||||
currentName: "kt Rolster",
|
||||
aliases: ["kt Rolster", "KT", "KT Rolster", "케이티", "케이티 롤스터"],
|
||||
},
|
||||
{
|
||||
canonicalId: "ns",
|
||||
teamIds: ["102747101565183056"],
|
||||
currentName: "NONGSHIM RED FORCE",
|
||||
aliases: ["NONGSHIM RED FORCE", "Nongshim", "NS", "농심", "농심 레드포스", "Team Dynamics"],
|
||||
},
|
||||
{
|
||||
canonicalId: "bro",
|
||||
teamIds: ["105505619546859895"],
|
||||
currentName: "HANJIN BRION",
|
||||
aliases: ["HANJIN BRION", "BRION", "BRO", "OKSavingsBank BRION", "Fredit BRION", "브리온", "한진 브리온"],
|
||||
},
|
||||
{
|
||||
canonicalId: "bfx",
|
||||
teamIds: ["100725845022060229"],
|
||||
currentName: "BNK FEARX",
|
||||
aliases: ["BNK FEARX", "FEARX", "BFX", "Liiv SANDBOX", "SANDBOX Gaming", "리브 샌드박스", "샌드박스", "피어엑스"],
|
||||
},
|
||||
{
|
||||
canonicalId: "drx",
|
||||
teamIds: ["99566404585387054"],
|
||||
currentName: "KIWOOM DRX",
|
||||
aliases: ["KIWOOM DRX", "DRX", "DragonX", "Kingzone DragonX", "킹존 드래곤X", "드래곤X", "키움 DRX"],
|
||||
},
|
||||
{
|
||||
canonicalId: "dnf",
|
||||
teamIds: ["99566404581868574"],
|
||||
currentName: "DN SOOPers",
|
||||
aliases: [
|
||||
"DN SOOPers",
|
||||
"DNS",
|
||||
"DN FREECS",
|
||||
"DNF",
|
||||
"Kwangdong Freecs",
|
||||
"KDF",
|
||||
"광동 프릭스",
|
||||
"광동",
|
||||
"Afreeca Freecs",
|
||||
"아프리카 프릭스",
|
||||
"Freecs"
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const REGISTRY_BY_ID = new Map();
|
||||
const REGISTRY_BY_TOKEN = new Map();
|
||||
|
||||
for (const entry of TEAM_REGISTRY) {
|
||||
entry.aliasTokens = new Set();
|
||||
|
||||
for (const tokenSource of [entry.canonicalId, entry.currentName, ...(entry.aliases || [])]) {
|
||||
const token = normalizeToken(tokenSource);
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.aliasTokens.add(token);
|
||||
REGISTRY_BY_TOKEN.set(token, entry);
|
||||
}
|
||||
|
||||
for (const teamId of entry.teamIds || []) {
|
||||
REGISTRY_BY_ID.set(String(teamId), entry);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTeamQuery(query) {
|
||||
const input = String(query || "").trim();
|
||||
const token = normalizeToken(input);
|
||||
const entry = REGISTRY_BY_TOKEN.get(token);
|
||||
|
||||
if (!entry) {
|
||||
return {
|
||||
input,
|
||||
token,
|
||||
canonicalId: null,
|
||||
currentName: input,
|
||||
aliasTokens: new Set(token ? [token] : []),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
input,
|
||||
token,
|
||||
canonicalId: entry.canonicalId,
|
||||
currentName: entry.currentName,
|
||||
aliasTokens: new Set(entry.aliasTokens),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTeamPayload(team) {
|
||||
const teamId = String(team?.id || "");
|
||||
const entry = REGISTRY_BY_ID.get(teamId)
|
||||
|| REGISTRY_BY_TOKEN.get(normalizeToken(team?.name))
|
||||
|| REGISTRY_BY_TOKEN.get(normalizeToken(team?.code))
|
||||
|| REGISTRY_BY_TOKEN.get(normalizeToken(team?.slug));
|
||||
|
||||
if (!entry) {
|
||||
return {
|
||||
id: team?.id || null,
|
||||
slug: team?.slug || null,
|
||||
code: team?.code || null,
|
||||
name: team?.name || null,
|
||||
image: team?.image || null,
|
||||
canonicalId: null,
|
||||
currentName: team?.name || null,
|
||||
aliasTokens: new Set([
|
||||
normalizeToken(team?.id),
|
||||
normalizeToken(team?.slug),
|
||||
normalizeToken(team?.code),
|
||||
normalizeToken(team?.name),
|
||||
].filter(Boolean)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: team?.id || null,
|
||||
slug: team?.slug || null,
|
||||
code: team?.code || null,
|
||||
name: team?.name || entry.currentName,
|
||||
image: team?.image || null,
|
||||
canonicalId: entry.canonicalId,
|
||||
currentName: entry.currentName,
|
||||
aliasTokens: new Set([
|
||||
normalizeToken(team?.id),
|
||||
normalizeToken(team?.slug),
|
||||
normalizeToken(team?.code),
|
||||
normalizeToken(team?.name),
|
||||
...entry.aliasTokens,
|
||||
].filter(Boolean)),
|
||||
};
|
||||
}
|
||||
|
||||
function stripAliasTokens(team) {
|
||||
return {
|
||||
id: team.id,
|
||||
slug: team.slug,
|
||||
code: team.code,
|
||||
name: team.name,
|
||||
image: team.image,
|
||||
canonicalId: team.canonicalId,
|
||||
currentName: team.currentName,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TEAM_REGISTRY,
|
||||
normalizeToken,
|
||||
resolveTeamPayload,
|
||||
resolveTeamQuery,
|
||||
stripAliasTokens,
|
||||
};
|
||||
47
packages/lck-analytics/test/fixtures/event-details-2026-04-01.json
vendored
Normal file
47
packages/lck-analytics/test/fixtures/event-details-2026-04-01.json
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"data": {
|
||||
"event": {
|
||||
"id": "match-1",
|
||||
"streams": [
|
||||
{
|
||||
"parameter": "lck",
|
||||
"locale": "ko-KR",
|
||||
"provider": "afreeca"
|
||||
}
|
||||
],
|
||||
"match": {
|
||||
"games": [
|
||||
{
|
||||
"id": "game-1",
|
||||
"number": 1,
|
||||
"state": "inProgress",
|
||||
"teams": [
|
||||
{
|
||||
"id": "100205573496804586",
|
||||
"side": "blue"
|
||||
},
|
||||
{
|
||||
"id": "98767991853197861",
|
||||
"side": "red"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"teams": [
|
||||
{
|
||||
"id": "100205573496804586",
|
||||
"name": "Hanwha Life Esports",
|
||||
"code": "HLE",
|
||||
"slug": "hanwha-life-esports"
|
||||
},
|
||||
{
|
||||
"id": "98767991853197861",
|
||||
"name": "T1",
|
||||
"code": "T1",
|
||||
"slug": "t1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
packages/lck-analytics/test/fixtures/live-details-game-1.json
vendored
Normal file
88
packages/lck-analytics/test/fixtures/live-details-game-1.json
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"esportsGameId": "game-1",
|
||||
"esportsMatchId": "match-1",
|
||||
"frames": [
|
||||
{
|
||||
"rfc460Timestamp": "2026-04-01T09:00:00.000Z",
|
||||
"participants": [
|
||||
{
|
||||
"participantId": 1,
|
||||
"level": 12,
|
||||
"kills": 2,
|
||||
"deaths": 0,
|
||||
"assists": 4,
|
||||
"creepScore": 138,
|
||||
"currentGold": 1200
|
||||
},
|
||||
{
|
||||
"participantId": 2,
|
||||
"level": 10,
|
||||
"kills": 0,
|
||||
"deaths": 2,
|
||||
"assists": 1,
|
||||
"creepScore": 112,
|
||||
"currentGold": 800
|
||||
},
|
||||
{
|
||||
"participantId": 6,
|
||||
"level": 11,
|
||||
"kills": 1,
|
||||
"deaths": 1,
|
||||
"assists": 0,
|
||||
"creepScore": 126,
|
||||
"currentGold": 950
|
||||
},
|
||||
{
|
||||
"participantId": 7,
|
||||
"level": 9,
|
||||
"kills": 0,
|
||||
"deaths": 2,
|
||||
"assists": 1,
|
||||
"creepScore": 101,
|
||||
"currentGold": 700
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rfc460Timestamp": "2026-04-01T09:05:00.000Z",
|
||||
"participants": [
|
||||
{
|
||||
"participantId": 1,
|
||||
"level": 14,
|
||||
"kills": 5,
|
||||
"deaths": 0,
|
||||
"assists": 6,
|
||||
"creepScore": 165,
|
||||
"currentGold": 2100
|
||||
},
|
||||
{
|
||||
"participantId": 2,
|
||||
"level": 12,
|
||||
"kills": 0,
|
||||
"deaths": 4,
|
||||
"assists": 2,
|
||||
"creepScore": 130,
|
||||
"currentGold": 900
|
||||
},
|
||||
{
|
||||
"participantId": 6,
|
||||
"level": 12,
|
||||
"kills": 1,
|
||||
"deaths": 3,
|
||||
"assists": 0,
|
||||
"creepScore": 140,
|
||||
"currentGold": 1100
|
||||
},
|
||||
{
|
||||
"participantId": 7,
|
||||
"level": 11,
|
||||
"kills": 0,
|
||||
"deaths": 3,
|
||||
"assists": 1,
|
||||
"creepScore": 119,
|
||||
"currentGold": 750
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
118
packages/lck-analytics/test/fixtures/live-window-game-1.json
vendored
Normal file
118
packages/lck-analytics/test/fixtures/live-window-game-1.json
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"esportsGameId": "game-1",
|
||||
"esportsMatchId": "match-1",
|
||||
"gameMetadata": {
|
||||
"patchVersion": "16.6.753.8272",
|
||||
"blueTeamMetadata": {
|
||||
"participantMetadata": [
|
||||
{
|
||||
"participantId": 1,
|
||||
"summonerName": "HLE Zeus",
|
||||
"championId": "Aatrox",
|
||||
"role": "top"
|
||||
},
|
||||
{
|
||||
"participantId": 2,
|
||||
"summonerName": "HLE Peanut",
|
||||
"championId": "Vi",
|
||||
"role": "jungle"
|
||||
}
|
||||
]
|
||||
},
|
||||
"redTeamMetadata": {
|
||||
"participantMetadata": [
|
||||
{
|
||||
"participantId": 6,
|
||||
"summonerName": "T1 Doran",
|
||||
"championId": "Gnar",
|
||||
"role": "top"
|
||||
},
|
||||
{
|
||||
"participantId": 7,
|
||||
"summonerName": "T1 Oner",
|
||||
"championId": "Sejuani",
|
||||
"role": "jungle"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"rfc460Timestamp": "2026-04-01T09:00:00.000Z",
|
||||
"gameState": "in_game",
|
||||
"blueTeam": {
|
||||
"totalGold": 24000,
|
||||
"inhibitors": 0,
|
||||
"towers": 3,
|
||||
"barons": 0,
|
||||
"totalKills": 2,
|
||||
"dragons": [
|
||||
"infernal"
|
||||
],
|
||||
"participants": [
|
||||
{
|
||||
"participantId": 1
|
||||
},
|
||||
{
|
||||
"participantId": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"redTeam": {
|
||||
"totalGold": 23200,
|
||||
"inhibitors": 0,
|
||||
"towers": 2,
|
||||
"barons": 0,
|
||||
"totalKills": 1,
|
||||
"dragons": [],
|
||||
"participants": [
|
||||
{
|
||||
"participantId": 6
|
||||
},
|
||||
{
|
||||
"participantId": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"rfc460Timestamp": "2026-04-01T09:05:00.000Z",
|
||||
"gameState": "in_game",
|
||||
"blueTeam": {
|
||||
"totalGold": 29800,
|
||||
"inhibitors": 0,
|
||||
"towers": 5,
|
||||
"barons": 1,
|
||||
"totalKills": 6,
|
||||
"dragons": [
|
||||
"infernal",
|
||||
"hextech"
|
||||
],
|
||||
"participants": [
|
||||
{
|
||||
"participantId": 1
|
||||
},
|
||||
{
|
||||
"participantId": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"redTeam": {
|
||||
"totalGold": 26200,
|
||||
"inhibitors": 0,
|
||||
"towers": 2,
|
||||
"barons": 0,
|
||||
"totalKills": 1,
|
||||
"dragons": [],
|
||||
"participants": [
|
||||
{
|
||||
"participantId": 6
|
||||
},
|
||||
{
|
||||
"participantId": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
5
packages/lck-analytics/test/fixtures/oracle-sample.csv
vendored
Normal file
5
packages/lck-analytics/test/fixtures/oracle-sample.csv
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
league,matchid,date,patch,side,teamname,opponentteam,playername,position,champion,opponentchampion,result,gd15,csd15,xpd15,drg,bn,blindpick,counterpick
|
||||
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Zeus,top,Aatrox,Gnar,win,1200,18,340,100,100,0,1
|
||||
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Peanut,jungle,Vi,Sejuani,win,800,5,280,100,100,1,0
|
||||
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Doran,top,Gnar,Aatrox,loss,-1200,-18,-340,0,0,1,0
|
||||
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Oner,jungle,Sejuani,Vi,loss,-800,-5,-280,0,0,0,1
|
||||
|
57
packages/lck-analytics/test/fixtures/schedule-2026-04-01.json
vendored
Normal file
57
packages/lck-analytics/test/fixtures/schedule-2026-04-01.json
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"data": {
|
||||
"schedule": {
|
||||
"pages": {
|
||||
"older": null,
|
||||
"newer": null
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"id": "event-1",
|
||||
"startTime": "2026-04-01T09:00:00.000Z",
|
||||
"state": "inProgress",
|
||||
"blockName": "Regular Season",
|
||||
"league": {
|
||||
"slug": "lck",
|
||||
"name": "LCK"
|
||||
},
|
||||
"tournament": {
|
||||
"id": "tournament-2026",
|
||||
"name": "LCK 2026 Spring",
|
||||
"slug": "lck-2026-spring"
|
||||
},
|
||||
"match": {
|
||||
"id": "match-1",
|
||||
"strategy": {
|
||||
"type": "bestOf",
|
||||
"count": 3
|
||||
},
|
||||
"teams": [
|
||||
{
|
||||
"id": "100205573496804586",
|
||||
"name": "Hanwha Life Esports",
|
||||
"code": "HLE",
|
||||
"slug": "hanwha-life-esports",
|
||||
"result": {
|
||||
"gameWins": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "98767991853197861",
|
||||
"name": "T1",
|
||||
"code": "T1",
|
||||
"slug": "t1",
|
||||
"result": {
|
||||
"gameWins": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"flags": [
|
||||
"featured"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
52
packages/lck-analytics/test/fixtures/standings-2026.json
vendored
Normal file
52
packages/lck-analytics/test/fixtures/standings-2026.json
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"data": {
|
||||
"standings": [
|
||||
{
|
||||
"stages": [
|
||||
{
|
||||
"id": "stage-1",
|
||||
"name": "Regular Season",
|
||||
"slug": "regular-season",
|
||||
"sections": [
|
||||
{
|
||||
"name": "Rankings",
|
||||
"rankings": [
|
||||
{
|
||||
"ordinal": 1,
|
||||
"teams": [
|
||||
{
|
||||
"id": "100205573496804586",
|
||||
"name": "Hanwha Life Esports",
|
||||
"code": "HLE",
|
||||
"slug": "hanwha-life-esports",
|
||||
"record": {
|
||||
"wins": 5,
|
||||
"losses": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"teams": [
|
||||
{
|
||||
"id": "98767991853197861",
|
||||
"name": "T1",
|
||||
"code": "T1",
|
||||
"slug": "t1",
|
||||
"record": {
|
||||
"wins": 4,
|
||||
"losses": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
18
packages/lck-analytics/test/fixtures/tournaments-2026.json
vendored
Normal file
18
packages/lck-analytics/test/fixtures/tournaments-2026.json
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"data": {
|
||||
"leagues": [
|
||||
{
|
||||
"id": "98767991310872058",
|
||||
"slug": "lck",
|
||||
"tournaments": [
|
||||
{
|
||||
"id": "tournament-2026",
|
||||
"slug": "lck-2026-spring",
|
||||
"startDate": "2026-03-01",
|
||||
"endDate": "2026-05-01"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
170
packages/lck-analytics/test/index.test.js
Normal file
170
packages/lck-analytics/test/index.test.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
buildHistoricalAnalytics,
|
||||
getGameAnalysis,
|
||||
getLckSummary,
|
||||
getMatchAnalysis,
|
||||
getMatchResults,
|
||||
getStandings,
|
||||
parseOracleCsv,
|
||||
} = require("../src/index");
|
||||
const {
|
||||
normalizeScheduleResponse,
|
||||
normalizeStandingsResponse,
|
||||
} = require("../src/parse");
|
||||
|
||||
const fixturesDir = path.join(__dirname, "fixtures");
|
||||
|
||||
function readFixture(name) {
|
||||
return JSON.parse(fs.readFileSync(path.join(fixturesDir, name), "utf8"));
|
||||
}
|
||||
|
||||
const schedulePayload = readFixture("schedule-2026-04-01.json");
|
||||
const tournamentsPayload = readFixture("tournaments-2026.json");
|
||||
const standingsPayload = readFixture("standings-2026.json");
|
||||
const eventDetailsPayload = readFixture("event-details-2026-04-01.json");
|
||||
const liveWindowPayload = readFixture("live-window-game-1.json");
|
||||
const liveDetailsPayload = readFixture("live-details-game-1.json");
|
||||
const oracleCsv = fs.readFileSync(path.join(fixturesDir, "oracle-sample.csv"), "utf8");
|
||||
|
||||
test("normalizeScheduleResponse filters requested LCK date and Korean team aliases", () => {
|
||||
const result = normalizeScheduleResponse(schedulePayload, {
|
||||
date: "2026-04-01",
|
||||
team: "한화",
|
||||
});
|
||||
|
||||
assert.equal(result.queryDate, "2026-04-01");
|
||||
assert.equal(result.matches.length, 1);
|
||||
assert.equal(result.filteredTeam.canonicalId, "hle");
|
||||
assert.equal(result.matches[0].team1.currentName, "Hanwha Life Esports");
|
||||
assert.equal(result.matches[0].team2.currentName, "T1");
|
||||
assert.deepEqual(result.matches[0].score, { team1: 1, team2: 0 });
|
||||
});
|
||||
|
||||
test("normalizeStandingsResponse keeps the LCK standings shape and alias resolution", () => {
|
||||
const table = normalizeStandingsResponse(standingsPayload, {
|
||||
tournament: {
|
||||
id: "tournament-2026",
|
||||
slug: "lck-2026-spring",
|
||||
name: "LCK 2026 Spring",
|
||||
},
|
||||
team: "T1",
|
||||
});
|
||||
|
||||
assert.equal(table.tournamentId, "tournament-2026");
|
||||
assert.equal(table.rows.length, 1);
|
||||
assert.equal(table.rows[0].team.canonicalId, "t1");
|
||||
assert.equal(table.rows[0].wins, 4);
|
||||
assert.equal(table.rows[0].losses, 2);
|
||||
});
|
||||
|
||||
test("public fetchers compose summary, standings, live details, and match analysis", async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
|
||||
global.fetch = async (url, options = {}) => {
|
||||
const target = String(url);
|
||||
calls.push({
|
||||
target,
|
||||
headers: options.headers || {},
|
||||
});
|
||||
|
||||
if (target.includes("getSchedule")) {
|
||||
return makeResponse(schedulePayload);
|
||||
}
|
||||
|
||||
if (target.includes("getTournamentsForLeague")) {
|
||||
return makeResponse(tournamentsPayload);
|
||||
}
|
||||
|
||||
if (target.includes("getStandings")) {
|
||||
return makeResponse(standingsPayload);
|
||||
}
|
||||
|
||||
if (target.includes("getEventDetails")) {
|
||||
return makeResponse(eventDetailsPayload);
|
||||
}
|
||||
|
||||
if (target.includes("/window/game-1")) {
|
||||
return makeResponse(liveWindowPayload);
|
||||
}
|
||||
|
||||
if (target.includes("/details/game-1")) {
|
||||
return makeResponse(liveDetailsPayload);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${target}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const results = await getMatchResults("2026-04-01", { team: "한화" });
|
||||
const standings = await getStandings({ date: "2026-04-01", team: "T1" });
|
||||
const summary = await getLckSummary("2026-04-01", {
|
||||
team: "한화",
|
||||
includeStandings: true,
|
||||
});
|
||||
const analysis = await getMatchAnalysis("2026-04-01", {
|
||||
team: "한화",
|
||||
historicalDataset: buildHistoricalAnalytics(oracleCsv),
|
||||
});
|
||||
|
||||
assert.equal(results.matches.length, 1);
|
||||
assert.equal(results.matches[0].games[0].teams[0].team.currentName, "Hanwha Life Esports");
|
||||
assert.equal(results.matches[0].live.killDiff, 5);
|
||||
assert.equal(standings.rows[0].team.currentName, "T1");
|
||||
assert.equal(summary.standings.rows[0].team.currentName, "Hanwha Life Esports");
|
||||
assert.equal(analysis.matches[0].analyses[0].draft.overallEdge, "blue");
|
||||
assert.equal(analysis.matches[0].powerPreview.teamA.teamId, "hle");
|
||||
assert.ok(
|
||||
calls.some((call) => call.headers["x-api-key"]),
|
||||
"expected Riot API requests to include an x-api-key header",
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("historical analytics parse Oracle-style CSV and power rankings", () => {
|
||||
const parsedRows = parseOracleCsv(oracleCsv);
|
||||
const historical = buildHistoricalAnalytics(parsedRows);
|
||||
|
||||
assert.equal(parsedRows.length, 4);
|
||||
assert.equal(historical.rows.length, 4);
|
||||
assert.equal(historical.teamPowerRatings[0].teamId, "hle");
|
||||
assert.equal(historical.teamPowerRatings[0].wins, 2);
|
||||
assert.equal(historical.patchMeta[0].patch, "16.6.753.8272");
|
||||
assert.equal(historical.matchupStats[0].champion, "Aatrox");
|
||||
});
|
||||
|
||||
test("getGameAnalysis computes turning points and draft context from injected live payloads", async () => {
|
||||
const historical = buildHistoricalAnalytics(oracleCsv);
|
||||
const analysis = await getGameAnalysis("game-1", {
|
||||
matchId: "match-1",
|
||||
number: 1,
|
||||
state: "inProgress",
|
||||
historicalDataset: historical,
|
||||
liveWindowPayload,
|
||||
liveDetailsPayload,
|
||||
});
|
||||
|
||||
assert.equal(analysis.gameId, "game-1");
|
||||
assert.equal(analysis.patch, "16.6.753.8272");
|
||||
assert.equal(analysis.current.goldDiff, 3600);
|
||||
assert.ok(analysis.turningPoints.length >= 1);
|
||||
assert.equal(analysis.turningPoints[0].favoredSide, "blue");
|
||||
assert.equal(analysis.draft.roleMatchups[0].role, "top");
|
||||
assert.equal(analysis.meta.topPicks[0].champion, "Aatrox");
|
||||
});
|
||||
|
||||
function makeResponse(body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -179,10 +179,39 @@ test("repository docs advertise the used-car-price-search skill", () => {
|
|||
assert.match(install, /--skill used-car-price-search/);
|
||||
assert.match(
|
||||
install,
|
||||
/npm install -g @ohah\/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp/,
|
||||
/npm install -g @ohah\/hwpjs kbo-game kleague-results lck-analytics toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp/,
|
||||
);
|
||||
});
|
||||
|
||||
test("repository docs advertise the lck-analytics skill and package", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "lck-analytics.md");
|
||||
const skillPath = path.join(repoRoot, "lck-analytics", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/lck-analytics.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected lck-analytics/SKILL.md to exist");
|
||||
assert.match(readme, /\| LCK 경기 분석 \|/);
|
||||
assert.match(readme, /\[LCK 경기 분석 가이드\]\(docs\/features\/lck-analytics\.md\)/);
|
||||
assert.match(install, /--skill lck-analytics/);
|
||||
assert.match(install, /npm install -g .*lck-analytics/);
|
||||
});
|
||||
|
||||
test("lck-analytics docs and skill credit the original author and reference repo", () => {
|
||||
const skill = read(path.join("lck-analytics", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "lck-analytics.md"));
|
||||
const packageReadme = read(path.join("packages", "lck-analytics", "README.md"));
|
||||
const sources = read(path.join("docs", "sources.md"));
|
||||
|
||||
for (const doc of [skill, featureDoc, packageReadme]) {
|
||||
assert.match(doc, /jerjangmin/);
|
||||
assert.match(doc, /https:\/\/github\.com\/jerjangmin\/share\/tree\/main\/SKILL\/lck-analytics/);
|
||||
assert.match(doc, /Riot|LoL Esports|Oracle(?:'s)? Elixir/i);
|
||||
}
|
||||
|
||||
assert.match(sources, /https:\/\/github\.com\/jerjangmin\/share\/tree\/main\/SKILL\/lck-analytics/);
|
||||
});
|
||||
|
||||
test("used-car-price-search docs document the provider survey and SK direct surface", () => {
|
||||
const skill = read(path.join("used-car-price-search", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "used-car-price-search.md"));
|
||||
|
|
@ -684,6 +713,7 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
|
|||
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace kleague-results/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace lck-analytics/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the kleague-results skill across the documented surfaces", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue