델타포스 디시인사이드 대회 결과로 1/2/3티어의 승격·강등을 계산해 보여주는 정적 사이트입니다. 데이터베이스가 없습니다. 성적표 전체가 주소(URL 해시)에 담기므로, 링크 하나만 공유하면 상대도 같은 화면을 봅니다.
npm install
npm run dev # 개발 서버 (HMR)
npm run build # dist/ 생성
npm run preview # 빌드 결과 확인
npm run lint # ESLint
npm run format # Prettier
npm run test # Vitest (점수 계산·URL 인코딩)종합 점수 = (K/D × 10) + (팀 수 + 1 − 팀 순위) × 2
티어별로 점수 내림차순 정렬한 뒤, 상위 promote명을 위 티어로, 하위 promote명을 아래 티어로 보냅니다.
1티어는 승격이, 3티어는 강등이 없으므로 각 티어 인원은 그대로 유지됩니다.
시즌 1 기준 최고점은 3티어의 에이전트햄부기(64.0, 10킬 2데스 · 우승팀)입니다.
한 시즌에 두 티어를 건너뛰는 규칙입니다. 3티어 선수가 1티어보다 강한데 한 단계씩만 올라가면 다음 시즌에도 여전히 제자리를 못 찾는 문제를 교정합니다.
자격 — 통합 순위 기준. 전체 선수를 점수순으로 줄 세워 티어 정원(= 팀 수)씩 자른 구간이 그 선수의 "실력 티어"입니다. 실제 티어와 실력 티어가 두 단계 이상 벌어지면 자격을 얻습니다. 티어 안에서 1등인지가 아니라 리그 전체에서 그 자리에 있을 자격이 증명되는지를 봅니다.
인원 보존 — 1:1 스왑. 자격자를 그냥 올리면 위 티어 인원이 늘어납니다. 그래서 2단 승급자와
2단 강등자를 짝지어 자리를 맞바꿉니다. 짝이 안 맞으면 적은 쪽에 맞추고, promote도 넘지 않습니다.
슬롯 소비. 2단 이동은 건너뛴 경계를 모두 지나므로, 지나간 경계의 일반 이동 인원을 하나씩 줄입니다. 이 처리가 없으면 2단 이동이 생길 때마다 티어 인원이 어긋납니다.
시즌 1에 적용하면 이렇게 됩니다 — 티어 인원은 7/7/7 그대로입니다.
| 선수 | 점수 | 이동 | 사유 |
|---|---|---|---|
| 에이전트햄부기 | 64.0 | 3티어 → 1티어 | 통합 1위. 실력 티어 0 |
| 델델델델델 | 54.0 | 2티어 → 1티어 | 일반 승격 |
| ZBCat | 18.0 | 3티어 → 2티어 | 통합 10위라 1티어 정원 밖 |
| 채로희 | 12.0 | 1티어 → 2티어 | 일반 강등 |
| 내가중빡이 | 10.7 | 1티어 → 3티어 | 통합 18위. 실력 티어 2 |
건너뛸 폭은 config/constants.js의 JUMP_SIZE입니다.
팀 순위 보너스가 최대 14점이라, 킬이 거의 없는 선수는 소속팀 성적만으로 순위가 갈립니다. 개인 기여를 더 강조하려면
src/lib/scoring.js의TEAM_RANK_WEIGHT를 낮추면 됩니다. 두 계수(KD_WEIGHT,TEAM_RANK_WEIGHT)는 이름 있는 상수라 푸터 문구도 자동으로 따라갑니다.
데이터는 src/data/seasons.js에 있습니다. 화면에 편집기는 없습니다.
시즌 추가는 seasons 배열 뒤에 같은 모양으로 하나 더 붙이면 됩니다. 드롭다운에 자동으로 나타나고,
기본 표시는 배열의 마지막(가장 최근) 시즌입니다.
잘못된 JSON은 무엇이 틀렸는지 알려주고 기본 데이터로 되돌아갑니다. 시즌 id가 겹치면 그것도 잡아냅니다.
계층이 한 방향으로만 의존합니다: config ← lib ← hooks ← components ← App.
아래층은 위층을 모르므로, 순수 로직(lib)은 화면 없이 그대로 테스트할 수 있습니다.
src/
config/constants.js 티어 수·화면 갈래·이동 방향 등 공용 이름 (단일 진실 공급원)
data/seasons.js 대회 결과 원본. 시즌 추가는 여기만 고칩니다
lib/scoring.js 점수·순위·다음 시즌 로스터 계산 (순수 함수)
lib/filters.js 리더보드 필터 규칙 + 드롭다운 항목 목록
lib/schema.js 주소로 들어온 데이터 검증 (한국어 메시지)
lib/urlCodec.js JSON ↔ 압축 해시 (lz-string, 예전 base64 링크 호환)
lib/format.js 표에 찍을 숫자 자릿수
hooks/useHashData.js 주소 해시를 상태의 원천으로 삼는 훅
hooks/useSeasonStandings.js 보고 있는 시즌과 그 계산 결과
hooks/usePlayerFilters.js 필터 상태 한 덩어리 + 걸러진 목록
components/ui/ 도메인을 모르는 재사용 부품 (Select · Avatar · Logo · icons)
components/ SiteHeader · TopBar · Leaderboard · NextSeason · TierBadge
styles/ tokens → base → layout → components/*.css 순으로 읽힘
artifact/ claude.ai 아티팩트 게시용 단일 HTML (구버전 디자인, 앱과 별개)
확장할 때 손대는 곳
| 하고 싶은 일 | 고칠 파일 |
|---|---|
| 시즌 추가 | data/seasons.js |
| 점수식 조정 | lib/scoring.js의 KD_WEIGHT · TEAM_RANK_WEIGHT |
| 티어 수 변경 | config/constants.js의 TIER_COUNT + styles/tokens.css의 --tier-* |
| 2단 → 3단 이동 | config/constants.js의 JUMP_SIZE |
| 필터 추가 | lib/filters.js + TopBar에 <Select> 한 줄 |
| 화면 갈래 추가 | config/constants.js의 VIEW + SiteHeader의 NAV_ITEMS |
| 색·서체 변경 | styles/tokens.css |
화면은 전적 검색 사이트 계열의 다크 리더보드 한 장입니다. 시즌·티어 드롭다운과 이름 검색으로 걸러 보고, 행 왼쪽 색 띠와 맨 오른쪽 칸이 이동 방향을 나타냅니다.
티어 칸은 승격·강등이 적용된 결과를 보여줍니다. 움직인 선수는 1티어 ←2티어처럼 원래 티어를 함께 답니다.
티어 필터도 적용 후 기준입니다.
주소에 데이터가 없으면 로드하는 순간 기본 데이터를 해시에 심습니다. 즉 주소창은 항상 데이터를 들고 있습니다.
URL에 들어가는 것은 원본 값입니다 — 선수 이름, 킬, 데스, 소속팀, 팀 순위, 티어(배열 순서), 시즌 정보. 점수와 K/D는 저장하지 않고 매번 계산합니다. 계산 결과까지 URL에 넣으면 나중에 점수식을 고쳤을 때 저장된 값과 화면 값이 어긋나기 때문입니다. 원본만 있으면 언제 열어도 같은 결과가 나옵니다.
주소창이 저장소이므로 길이가 곧 수명입니다. base64는 원본보다 33% 길어져 시즌이 쌓이면
브라우저·메신저의 링크 한계에 걸립니다. 그래서 lz-string으로 압축하고 c~ 접두사를 붙입니다.
| 시즌 수 | 압축 (현재) | base64 (예전) |
|---|---|---|
| 1 | 658자 | 982자 |
| 10 | 2,748자 | 9,658자 |
| 20 | 4,344자 | 19,298자 |
접두사가 없는 해시는 예전 base64 링크로 보고 그대로 읽으므로, 이미 공유된 링크는 계속 동작합니다.
main에 push하면 GitHub Actions가 lint → build → Pages 배포까지 처리합니다.
저장소 Settings → Pages → Source를 GitHub Actions로 한 번 지정해 주세요.
vite.config.js의 base: './' 덕분에 user.github.io/<repo>/ 하위 경로와 커스텀 도메인 루트 양쪽에서 모두 동작합니다.
{ "seasons": [ { "id": "S01", // 상단 드롭다운에 표시되는 이름 "label": "1회 대회", "promote": 2, // 티어별 승격·강등 인원 "teams": [ { "name": "G팀", "rank": 1, // 대회 최종 순위 // 배열 순서가 곧 티어입니다: [1티어, 2티어, 3티어] "players": [ ["믕봉믕", 11, 4], // [이름, 킬, 데스] ["델델델델델", 12, 3], ["에이전트햄부기", 10, 2], ], }, ], }, ], }