diff --git a/docs/pvp/README.md b/docs/pvp/README.md new file mode 100644 index 00000000..8b7b2432 --- /dev/null +++ b/docs/pvp/README.md @@ -0,0 +1,145 @@ +# Tokénmon PvP 문서 인덱스 + +상태: Draft v1 +범위: 온라인 친선 PvP 초기 설계 +기준 방향: **세대별 온라인 파티 등록 + 서버 권한 전투 + 인게임 감성 6v6 싱글** + +## 문서 목적 + +이 문서 세트는 Tokénmon의 온라인 PvP를 실제 구현 가능한 단위로 구조화한 상위/하위 설계 문서 모음이다. +핵심 목표는 다음 세 가지다. + +1. **전투 결과 위조를 막는다.** +2. **파티 성장 상태 위조를 최대한 막는다.** +3. **공식 대회 느낌보다 인게임에서 NPC 트레이너를 조우한 감성에 가깝게 만든다.** + +이 문서 세트는 아직 구현 문서가 아니라 **설계 기준 문서**다. 이후 서버/API/클라이언트 구현은 이 문서를 기준으로 분해한다. + +--- + +## 한눈에 보는 핵심 결정 + +- 전투는 **서버 권한(server-authoritative)** 으로 처리한다. +- 클라이언트(Claude Code)는 **기술 선택 / 교체 / 다음 포켓몬 선택** 같은 명령만 보낸다. +- 로컬 스토리/성장은 유지하되, 온라인에서는 **세대별 등록 파티 스냅샷**을 사용한다. +- 온라인 친선 PvP는 초기 버전에서 **싱글배틀 / 6마리 등록 / 6마리 전원 사용 / 팀 프리뷰 없음 / 1번 슬롯 선발** 로 간다. +- **동일 종 중복 금지**를 적용한다. +- **전설+환상 총 2마리 제한**, 그중 **restricted 1마리 제한**을 적용한다. +- 레벨은 실제 성장을 보존하되, 배틀 계산에서는 **50 이후 압축**, **유효 레벨 상한 60**을 둔다. +- `legendary`, `mythical`과 별개로 온라인 밸런스용 **`restricted` 개념을 따로 둔다.** +- 세대는 현재 로컬 시스템처럼 분리되어 있으므로, 온라인도 **세대별 ruleset / party / 룸** 구조로 간다. + +--- + +## 권장 읽기 순서 + +1. [PvP 제품/게임플레이 규칙](./game-design/battle-format.md) +2. [성장/파티 등록 구조](./game-design/progression-and-party-registration.md) +3. [특수 포켓몬 정책](./game-design/special-pokemon-policy.md) +4. [세대별 ruleset 구조](./game-design/generation-rules.md) +5. [서버 아키텍처](./server/architecture.md) +6. [Storage 인덱스](./server/storage/README.md) +7. [HTTP / WebSocket API 계약 초안](./server/api-contract.md) +8. [Contracts 인덱스](./server/contracts/README.md) +9. [실시간 배틀 흐름](./server/battle-flow.md) +10. [치트 대응 / 보안 정책](./security/anti-cheat.md) +11. [구현 로드맵](./roadmap/rollout-plan.md) +12. [서버 패키지 / 모듈 구조 제안](./implementation/server-package-layout.md) + +--- + +## 문서 트리 + +### 1. 게임 설계 +- [게임 설계 인덱스](./game-design/README.md) + - [배틀 포맷](./game-design/battle-format.md) + - [성장 및 파티 등록](./game-design/progression-and-party-registration.md) + - [전설 / 환상 / restricted 정책](./game-design/special-pokemon-policy.md) + - [세대별 ruleset 설계](./game-design/generation-rules.md) + +### 2. 서버 설계 +- [서버 설계 인덱스](./server/README.md) + - [서버 아키텍처](./server/architecture.md) + - [Storage 인덱스](./server/storage/README.md) + - [데이터 모델](./server/storage/data-model.md) + - [DB 스키마 초안](./server/storage/database-schema.md) + - [API 계약](./server/api-contract.md) + - [Contracts 인덱스](./server/contracts/README.md) + - [온라인 파티 등록 상세 계약](./server/contracts/party-registration.md) + - [친구전 룸 / 매치 성립 상세 계약](./server/contracts/room-and-match.md) + - [실시간 배틀 세션 상세 계약](./server/contracts/realtime-battle-session.md) + - [실시간 배틀 흐름](./server/battle-flow.md) + +### 3. 보안 / 운영 +- [보안 / 치트 대응 인덱스](./security/README.md) + - [치트 대응 정책](./security/anti-cheat.md) +- [로드맵 인덱스](./roadmap/README.md) + - [단계별 구현 로드맵](./roadmap/rollout-plan.md) + +### 4. 구현 계획 +- [구현 계획 인덱스](./implementation/README.md) + - [PvP 초기 구현 PRD](./implementation/prd.md) + - [PvP 작업 분해 / TODO](./implementation/todo-breakdown.md) + - [서버 패키지 / 모듈 구조 제안](./implementation/server-package-layout.md) + +--- + +## 문서 간 관계 + +- [배틀 포맷](./game-design/battle-format.md)은 플레이어 경험과 경기 규칙의 기준 문서다. +- [성장 및 파티 등록](./game-design/progression-and-party-registration.md)은 로컬 성장과 온라인 사용을 연결하는 문서다. +- [특수 포켓몬 정책](./game-design/special-pokemon-policy.md)과 [세대별 ruleset 설계](./game-design/generation-rules.md)는 배틀 포맷의 세부 제약을 정의한다. +- [서버 아키텍처](./server/architecture.md)는 왜 서버 권한 구조가 필요한지 설명한다. +- [Storage 인덱스](./server/storage/README.md)는 저장 계층 하위 묶음의 출발점이다. +- [데이터 모델](./server/storage/data-model.md)은 엔티티 개념도를 설명하고, [서버 DB 스키마 초안](./server/storage/database-schema.md)은 이를 컬럼/제약 수준으로 구체화한다. +- [API 계약](./server/api-contract.md)은 서버 아키텍처와 스키마를 전체 입출력 표면으로 정리한 문서다. +- [Contracts 인덱스](./server/contracts/README.md)는 Phase별 상세 계약 하위 묶음의 출발점이다. +- [온라인 파티 등록 상세 계약](./server/contracts/party-registration.md)은 그중 Phase 1 등록/조회 계약을 필드 단위까지 세밀하게 내린 문서다. +- [친구전 룸 / 매치 성립 상세 계약](./server/contracts/room-and-match.md)은 Phase 2의 room binding / presence / battle freeze 계약을 세밀하게 내린 문서다. +- [실시간 배틀 세션 상세 계약](./server/contracts/realtime-battle-session.md)은 Phase 3의 WebSocket 명령/이벤트 계약을 세밀하게 내린 문서다. +- [실시간 배틀 흐름](./server/battle-flow.md)은 실제 플레이 시퀀스를 정의한다. +- [치트 대응 정책](./security/anti-cheat.md)은 위 모든 문서의 보안 기준 문서다. +- [구현 로드맵](./roadmap/rollout-plan.md)은 이 설계를 어떤 순서로 구현할지 정리한 문서다. +- [PvP 초기 구현 PRD](./implementation/prd.md)은 제품 목표와 수용 기준을 정리한 문서다. +- [PvP 작업 분해 / TODO](./implementation/todo-breakdown.md)은 실제 개발 순서와 작업 단위를 정리한 문서다. +- [서버 패키지 / 모듈 구조 제안](./implementation/server-package-layout.md)은 이 작업들을 현재 repo 구조 안에서 어디에 구현할지 정리한 문서다. + +--- + +## 상위 결정 요약 + +| 항목 | 결정 | +|---|---| +| 전투 처리 권한 | 서버 권한 | +| 접속 방식 | Claude Code 클라이언트가 서버에 접속 | +| 초기 대전 타입 | 친선 PvP | +| 실시간성 | 실시간 진행 | +| 전투 포맷 | 싱글, 6v6, 팀 프리뷰 없음 | +| 선발 방식 | 1번 슬롯 자동 선발 | +| 교체 | 자유 교체 가능 | +| 기절 후 처리 | 다음 포켓몬 직접 선택 | +| 중복 종 | 금지 | +| 특수 포켓몬 제한 | 전설+환상 총 2, restricted 최대 1 | +| 성장 보존 | 실제 레벨 표시 유지 | +| 레벨 밸런싱 | 50 이후 압축, 유효 레벨 최대 60 | +| 온라인 파티 | 세대별 등록 스냅샷 1개 활성 | +| 재등록 | 허용 | +| 핵심 보안 목표 | 결과 위조 / 성장 상태 위조 방지 | + +--- + +## 관련 문서 + +- 상위 문서: [Docs Home](../README.md) +- 구현 우선순위: [구현 로드맵](./roadmap/rollout-plan.md) +- 구현 PRD: [PvP 초기 구현 PRD](./implementation/prd.md) +- 구현 작업 분해: [PvP 작업 분해 / TODO](./implementation/todo-breakdown.md) +- Storage 묶음 시작점: [Storage 인덱스](./server/storage/README.md) +- Contracts 묶음 시작점: [Contracts 인덱스](./server/contracts/README.md) +- 등록 계약 상세: [온라인 파티 등록 상세 계약](./server/contracts/party-registration.md) +- 룸 계약 상세: [친구전 룸 / 매치 성립 상세 계약](./server/contracts/room-and-match.md) +- 실시간 세션 계약 상세: [실시간 배틀 세션 상세 계약](./server/contracts/realtime-battle-session.md) +- 코드 구조 제안: [서버 패키지 / 모듈 구조 제안](./implementation/server-package-layout.md) +- 데이터 중심 상세: [서버 데이터 모델](./server/storage/data-model.md) +- DB 구체안: [서버 DB 스키마 초안](./server/storage/database-schema.md) +- 보안 중심 상세: [치트 대응 정책](./security/anti-cheat.md) diff --git a/docs/pvp/game-design/README.md b/docs/pvp/game-design/README.md new file mode 100644 index 00000000..dfd1f9ab --- /dev/null +++ b/docs/pvp/game-design/README.md @@ -0,0 +1,18 @@ +# PvP 게임 설계 문서 + +상위 문서: [PvP 문서 인덱스](../README.md) + +이 섹션은 Tokénmon 온라인 PvP의 **플레이 경험과 규칙**을 정의한다. + +## 포함 문서 + +1. [배틀 포맷](./battle-format.md) +2. [성장 및 파티 등록](./progression-and-party-registration.md) +3. [전설 / 환상 / restricted 정책](./special-pokemon-policy.md) +4. [세대별 ruleset 설계](./generation-rules.md) + +## 권장 읽기 순서 + +- 먼저 [배틀 포맷](./battle-format.md)으로 플레이 감성을 본다. +- 다음 [성장 및 파티 등록](./progression-and-party-registration.md)으로 로컬 성장과 온라인 사용의 연결 방식을 본다. +- 그 다음 [전설 / 환상 / restricted 정책](./special-pokemon-policy.md)과 [세대별 ruleset 설계](./generation-rules.md)로 제약과 세대별 차이를 본다. diff --git a/docs/pvp/game-design/battle-format.md b/docs/pvp/game-design/battle-format.md new file mode 100644 index 00000000..f026a3da --- /dev/null +++ b/docs/pvp/game-design/battle-format.md @@ -0,0 +1,96 @@ +# PvP 배틀 포맷 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [성장 및 파티 등록](./progression-and-party-registration.md), [특수 포켓몬 정책](./special-pokemon-policy.md), [실시간 배틀 흐름](../server/battle-flow.md) + +## 목표 + +초기 Tokénmon PvP는 래더/대회 포맷보다 **인게임에서 갑자기 트레이너와 조우한 듯한 감성**을 우선한다. +즉, 유저는 미리 출전 3마리를 고르고 공개 정보를 많이 보는 방식보다, **자신이 키운 풀 파티를 그대로 들고 와서 실시간으로 판단하는 경험**을 하게 된다. + +## 핵심 포맷 + +- 배틀 타입: **싱글 배틀** +- 파티 크기: **6마리 등록 / 6마리 전원 사용** +- 선발: **1번 슬롯 자동 선발** +- 팀 프리뷰: **없음** +- 상대 백라인 공개: **없음** +- 교체: **턴 중 자유 교체 가능** +- 기절 시: **다음 포켓몬을 직접 선택** +- 타이머: **행동 선택당 45초** +- 중복 종: **금지** + +## 왜 이런 포맷인가 + +### 1. 인게임 감성을 유지한다 + +공식 대회식 팀 프리뷰와 선발 선택은 경쟁적으론 좋지만, Tokénmon이 주려는 감성인 “현장에서 즉시 싸움이 걸리는 느낌”과는 거리가 있다. +초기 친선 PvP에서는 룰의 완전한 공정성보다도 **게임의 정체성 유지**가 더 중요하다. + +### 2. 숨은 정보가 긴장감을 만든다 + +상대 엔트리를 모르는 상태에서 전투를 시작하면, 교체 판단과 기술 선택의 가치가 커진다. +이 구조는 특히 친선전에서 “예상 못 한 포켓몬이 뒤에서 나온다”는 재미를 만든다. + +### 3. 현재 배틀 엔진 구조와도 잘 맞는다 + +현 구조는 멀티 배틀보다 싱글 전투에 더 자연스럽게 맞는다. 따라서 초기 온라인 PvP는 싱글에 집중하는 것이 구현 난이도와 안정성 측면에서 맞다. + +## 레벨 정책 + +Tokénmon은 로컬 성장의 보람을 살리고 싶기 때문에, 공식 VGC처럼 모든 포켓몬을 일괄 레벨 50으로 만드는 방향은 초기 친선 PvP와 맞지 않는다. + +따라서 초기 룰은 다음처럼 간다. + +- 화면 표시 레벨: **실제 성장 레벨 그대로 유지** +- 배틀 계산용 유효 레벨: **50 이후 압축 적용** +- 유효 레벨 상한: **60** + +이 정책은 다음 균형을 노린다. + +- 토큰을 많이 써서 열심히 키운 가치가 남는다. +- 레벨 차이로 상대를 완전히 찍어누르는 문제는 줄인다. +- 고레벨 성장의 만족감은 남기되, PvP의 재미는 망치지 않는다. + +구체적인 압축 공식은 추후 세부 구현에서 정하되, 룰 상 의미는 “50 이후부터는 성장 효율이 완만해지고, 60 이상은 PvP 계산상 동일 상한”이다. + +## 행동 규칙 + +플레이어는 서버에 아래와 같은 종류의 행동만 보낸다. + +- 기술 선택 +- 교체 선택 +- 기절 후 다음 포켓몬 선택 +- 항복 + +클라이언트는 결과를 계산하지 않는다. 실제 우선순위, 대미지, 상태 변화, 승패 판정은 모두 서버가 수행한다. + +## 초기 범위에서 제외하는 것 + +- 래더 점수 시스템 +- 공개 팀 시트 +- 밴/픽 단계 +- 더블 배틀 +- 토너먼트 운영 기능 +- 관전/리플레이 공유의 완성형 버전 + +이들은 초기 친선 PvP가 안정화된 뒤 별도 규칙 세트로 분리하는 것이 맞다. + +## 확정 규칙 요약 + +| 항목 | 규칙 | +|---|---| +| 배틀 형식 | 싱글 | +| 사용 마릿수 | 6 | +| 팀 프리뷰 | 없음 | +| 선발 | 1번 슬롯 | +| 타이머 | 45초 | +| 중복 종 | 금지 | +| 레벨 표시 | 실제 레벨 | +| 레벨 계산 | 50 이후 압축, 60 상한 | + +## 하위 결정이 필요한 문서 + +- [성장 및 파티 등록](./progression-and-party-registration.md): 어떤 파티가 온라인에서 유효한가 +- [특수 포켓몬 정책](./special-pokemon-policy.md): 전설/환상/restricted 제한 +- [실시간 배틀 흐름](../server/battle-flow.md): 실제 턴 진행과 이벤트 시퀀스 diff --git a/docs/pvp/game-design/generation-rules.md b/docs/pvp/game-design/generation-rules.md new file mode 100644 index 00000000..28891bcd --- /dev/null +++ b/docs/pvp/game-design/generation-rules.md @@ -0,0 +1,126 @@ +# 세대별 ruleset 설계 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [성장 및 파티 등록](./progression-and-party-registration.md), [특수 포켓몬 정책](./special-pokemon-policy.md), [서버 데이터 모델](../server/storage/data-model.md) + +## 왜 세대별 ruleset이 필요한가 + +Tokénmon은 이미 세대별 데이터와 진행 구조를 갖고 있다. +따라서 온라인 PvP도 하나의 통합 규칙으로 뭉개기보다, **세대별 포켓몬 풀과 ruleset을 별도로 운영**하는 것이 자연스럽다. + +이 구조를 선택하면 다음이 가능하다. + +- gen1 친선전과 gen4 친선전의 메타를 분리 +- 세대별 restricted 목록 운용 +- 세대별 활성 등록 파티 1개 구조 유지 +- 향후 시즌/이벤트 룰 추가 시 확장 용이 + +## 공통 규칙 + +모든 세대 ruleset은 다음 기본 축을 공유한다. + +- 싱글 배틀 +- 6마리 등록 / 6마리 전원 사용 +- 팀 프리뷰 없음 +- 1번 슬롯 자동 선발 +- 중복 종 금지 +- 전설+환상 총 2 제한 +- restricted 최대 1 +- 레벨 50 이후 압축 / 유효 레벨 상한 60 + +## 세대별 활성 파티 + +플레이어는 세대마다 별도의 온라인 파티를 가진다. + +예: + +- `player A / gen1 / active_party` +- `player A / gen4 / active_party` +- `player A / gen9 / active_party` + +온라인 룸 생성 시에도 어느 세대 ruleset인지가 명확해야 한다. + +## 세대별 특수 포켓몬 수치 참고 + +현재 데이터 기준 특수 포켓몬 분포는 다음과 같다. + +| 세대 | 포켓몬 수 | legendary | mythical | +|---|---:|---:|---:| +| gen1 | 151 | 4 | 1 | +| gen2 | 100 | 5 | 1 | +| gen3 | 135 | 8 | 2 | +| gen4 | 112 | 9 | 5 | +| gen5 | 156 | 9 | 4 | +| gen6 | 72 | 3 | 3 | +| gen7 | 88 | 11 | 5 | +| gen8 | 96 | 11 | 1 | +| gen9 | 120 | 11 | 1 | + +이 수치는 곧 restricted 규칙이 세대별로 달라져야 함을 의미한다. + +## restricted 시드 리스트 v0 + +아래 목록은 초기 밸런싱 시작점이다. +확정 영구 규칙이라기보다, **첫 친선 PvP 운영을 위한 시드 목록**으로 본다. + +| 세대 | restricted 시드 후보 | +|---|---| +| gen1 | 150 Mewtwo | +| gen2 | 249 Lugia, 250 Ho-Oh | +| gen3 | 382 Kyogre, 383 Groudon, 384 Rayquaza | +| gen4 | 483 Dialga, 484 Palkia, 486 Regigigas, 487 Giratina, 493 Arceus | +| gen5 | 643 Reshiram, 644 Zekrom, 646 Kyurem | +| gen6 | 716 Xerneas, 717 Yveltal | +| gen7 | 791 Solgaleo, 792 Lunala, 800 Necrozma | +| gen8 | 888 Zacian, 889 Zamazenta, 890 Eternatus | +| gen9 | 1007 Koraidon, 1008 Miraidon | + +## gen4 현재 풀 기준 메모 + +현재 루트 데이터 기준 gen4 특수 포켓몬 풀에서는 다음 판단이 중요하다. + +### restricted로 두는 후보 +- Dialga +- Palkia +- Regigigas +- Giratina +- Arceus + +### restricted가 아닌 특수 포켓몬 예시 +- Uxie +- Mesprit +- Azelf +- Heatran +- Cresselia +- Phione +- Manaphy +- Darkrai +- Shaymin + +특히 Regigigas는 Tokénmon 구현 상태에서 원작의 약점이 충분히 재현되지 않는다면 restricted로 보는 편이 안전하다. + +## ruleset 버전 관리 + +세대 ruleset은 정적 파일 하나로 끝내지 말고, 서버에서 **버전 관리되는 정책 데이터**로 보는 것이 좋다. + +예: + +- `tkm-friendly-gen4-v1` +- `tkm-friendly-gen4-v2` +- `tkm-friendly-gen9-v1` + +이렇게 해야 restricted 조정, 레벨 정책 수정, 예외 처리 추가가 기존 배틀 기록과 충돌하지 않는다. + +## 설계 결론 + +온라인 PvP는 “한 개의 공통 모드”가 아니라 다음 구조로 본다. + +- 세대별 친선 ruleset +- 세대별 restricted 목록 +- 세대별 활성 온라인 파티 +- 룸 생성 시 세대와 ruleset이 고정되는 구조 + +## 다음 문서 + +- [서버 데이터 모델](../server/storage/data-model.md) +- [API 계약](../server/api-contract.md) diff --git a/docs/pvp/game-design/progression-and-party-registration.md b/docs/pvp/game-design/progression-and-party-registration.md new file mode 100644 index 00000000..9226cd27 --- /dev/null +++ b/docs/pvp/game-design/progression-and-party-registration.md @@ -0,0 +1,118 @@ +# 성장 및 파티 등록 구조 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [배틀 포맷](./battle-format.md), [세대별 ruleset 설계](./generation-rules.md), [치트 대응 정책](../security/anti-cheat.md), [서버 데이터 모델](../server/storage/data-model.md) + +## 핵심 원칙 + +Tokénmon의 온라인 PvP는 **로컬 스토리/성장을 유지**하면서도, 온라인 대전에서는 **서버가 검증한 파티 스냅샷만 사용**하는 구조로 간다. + +즉: + +- 성장은 로컬에서 한다. +- 온라인에서 싸울 때는 로컬 데이터를 그대로 실시간 참조하지 않는다. +- 서버에 등록한 파티 스냅샷을 기준으로 전투한다. + +이 구조는 사용자의 감성과 보안을 동시에 잡기 위한 절충안이다. + +## 왜 로컬 파티를 그대로 쓰면 안 되는가 + +현재 로컬 구조는 스토리/로컬 플레이 중심이다. + +- `Config.party`는 현재 파티 슬롯 정보다. +- 파티 크기 기본값은 3이다. +- 보유 포켓몬 상태는 종 ID 중심으로 관리되는 경향이 강하다. + +즉, 현재 로컬 데이터는 “온라인용 확정 출전 파티”를 표현하기에 적합하지 않다. +따라서 온라인 PvP는 로컬 파티와 별개의 **등록 파티 개념**을 두는 것이 맞다. + +## 등록 파티의 정의 + +등록 파티는 다음 조건을 만족하는 **서버 저장 스냅샷**이다. + +- 특정 **세대(gen1~gen9)** 에 속한다. +- 최대 6마리로 구성된다. +- 종 중복 금지 규칙을 만족한다. +- 특수 포켓몬 제한 규칙을 만족한다. +- 서버 검증 시점에 유효한 성장 상태를 갖는다. +- 이후 배틀에서는 이 스냅샷을 기준으로 사용된다. + +## 세대별 활성 파티 + +초기 설계는 **세대별로 활성 등록 파티 1개**를 둔다. + +예: + +- gen1 온라인 파티 1개 활성 +- gen2 온라인 파티 1개 활성 +- gen4 온라인 파티 1개 활성 + +이 구조를 선택하는 이유는 다음과 같다. + +1. 현재 게임이 세대별로 나뉘어 있다. +2. 세대마다 등장 포켓몬과 밸런스 기준이 다르다. +3. 서버도 세대별 ruleset을 적용해야 한다. +4. UX가 단순하다. + +추후 필요하면 세대별 다중 슬롯(예: gen4 파티 A/B/C)로 확장할 수 있지만, 초기에는 **세대당 1개 활성**이 가장 단순하다. + +## 재등록 정책 + +재등록은 허용한다. 다만 의미는 “현재 활성 파티를 최신 상태로 덮어쓴다”에 가깝다. + +즉 플레이어는: + +- 스토리에서 파티를 육성하고 +- 특정 세대 기준으로 온라인 파티를 다시 만들고 +- 서버에 재등록하여 최신 상태로 갱신할 수 있다. + +이 정책은 “키운 포켓몬을 가져오고 싶다”는 감성과 잘 맞는다. + +## 추천 등록 플로우 + +1. 플레이어가 특정 세대를 선택한다. +2. 클라이언트가 로컬 저장에서 해당 세대의 보유 포켓몬을 읽는다. +3. 플레이어가 온라인용 6마리를 고른다. +4. 클라이언트가 서버로 등록 요청을 보낸다. +5. 서버가 ruleset, 중복 종, 특수 포켓몬 제한, 치트 상태를 검증한다. +6. 검증 통과 시 서버가 해당 세대의 활성 등록 파티 스냅샷을 갱신한다. + +## 온라인 파티와 로컬 파티의 관계 + +둘은 연결되지만 동일한 것은 아니다. + +- 로컬 파티: 현재 스토리/로컬 플레이 편성 +- 온라인 파티: 온라인 PvP용 검증 완료 스냅샷 + +이렇게 분리해야 다음이 가능하다. + +- 스토리 진행과 온라인 밸런스를 분리 +- 온라인 시작 시점 검증 단순화 +- 치트/위조 데이터 차단 포인트 확보 +- 향후 시즌제 ruleset 대응 + +## UX 관점의 규칙 + +플레이어 입장에서는 “로컬에서 키운 포켓몬을 세대별 온라인 박스에 등록한다”는 개념으로 보이게 하는 것이 좋다. + +권장 UX 문구 예시: + +- `gen4 온라인 파티 등록` +- `현재 gen4 친선전 파티 갱신` +- `온라인 배틀용으로 이 파티를 등록` + +## 설계 결론 + +초기 온라인 PvP는 다음 구조로 고정한다. + +- 로컬 성장 유지 +- 온라인은 서버 등록 스냅샷 사용 +- 세대별 활성 등록 파티 1개 +- 재등록 허용 +- 로컬 `config.party`는 온라인 파티로 재사용하지 않음 + +## 다음에 읽을 문서 + +- [세대별 ruleset 설계](./generation-rules.md) +- [서버 데이터 모델](../server/storage/data-model.md) +- [치트 대응 정책](../security/anti-cheat.md) diff --git a/docs/pvp/game-design/special-pokemon-policy.md b/docs/pvp/game-design/special-pokemon-policy.md new file mode 100644 index 00000000..62d71516 --- /dev/null +++ b/docs/pvp/game-design/special-pokemon-policy.md @@ -0,0 +1,107 @@ +# 전설 / 환상 / restricted 정책 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [배틀 포맷](./battle-format.md), [세대별 ruleset 설계](./generation-rules.md), [치트 대응 정책](../security/anti-cheat.md) + +## 목표 + +Tokénmon은 전설과 환상을 아예 금지하는 방향이 아니라, **쓸 수는 있지만 판을 망치지 않도록 제한하는 방향**으로 간다. + +이는 다음 두 가지를 동시에 만족시키기 위한 결정이다. + +1. 유저가 공들여 얻은 특수 포켓몬의 가치를 살린다. +2. 친선전이 특정 최상위 포켓몬 몇 마리만의 싸움이 되지 않도록 막는다. + +## 기본 분류 + +초기 온라인 룰에서 포켓몬 관련 분류는 세 층으로 나뉜다. + +### 1. 세계관/수집 분류 + +- `legendary` +- `mythical` + +이 분류는 로컬 게임/도감/수집 의미를 유지한다. + +### 2. 온라인 밸런스 분류 + +- `restricted` + +이 분류는 온라인 PvP 밸런스를 위한 별도 개념이다. +즉, 어떤 포켓몬이 `legendary`이면서 `restricted`일 수도 있고, `mythical`이지만 `restricted`가 아닐 수도 있다. + +## 제한 규칙 + +초기 친선 PvP의 기본 규칙은 다음과 같다. + +- **전설 + 환상 총합 최대 2마리** +- 그중 **restricted 최대 1마리** + +예시: + +- 가능: 일반 4 + legendary 1 + mythical 1 +- 가능: 일반 5 + restricted 1 +- 불가: restricted 2 +- 불가: legendary/mythical 총합 3 + +## 왜 환상을 허용하는가 + +공식 포켓몬 대회에서는 환상이 종종 금지되지만, Tokénmon은 전제가 다르다. + +- Tokénmon에서는 환상을 “언제나 획득 가능한 특수 포켓몬”으로 운영할 수 있다. +- 따라서 접근성 문제 때문에 환상을 일괄 금지할 필요가 없다. +- 대신 밸런스 측면만 별도로 조정하면 된다. + +즉, Tokénmon에서 환상은 “이벤트 한정 희귀 개체”라기보다 “특수하지만 사용 가능한 카테고리”에 더 가깝다. + +## 왜 restricted를 따로 두는가 + +`legendary`와 `mythical`만으로는 실제 PvP 영향력을 충분히 설명하지 못한다. + +예를 들어: + +- 어떤 legendary는 분위기용/유틸 성격일 수 있다. +- 어떤 mythical은 충분히 강하지만 판을 혼자 지배하지는 않을 수 있다. +- 반대로 어떤 포켓몬은 능력/스탯/기술 조합 때문에 한 마리만으로도 메타를 심하게 왜곡할 수 있다. + +그래서 온라인 PvP는 “세계관 태그”와 “제한 태그”를 분리해야 한다. + +## restricted 판정 기준 + +특정 포켓몬이 restricted가 되는 기준은 다음과 같다. + +1. 단독 캐리력이 지나치게 높다. +2. 일반 파티 대비 대처 비용이 과도하다. +3. 숨은 정보 기반 친선전 포맷에서 압박이 지나치다. +4. 현재 Tokénmon 구현(예: 특성 부재) 때문에 원작보다 더 강해진다. + +특히 Tokénmon은 현재 원작과 완전히 동일한 시스템이 아니므로, restricted 선정도 **원작 대회 리스트를 복사하지 않고 TKM 기준으로 재평가**해야 한다. + +## Regigigas 같은 예외 + +원작에서는 특정 특성 때문에 약점이 있는 포켓몬도, Tokénmon 구현 상태에 따라 그 약점이 사라질 수 있다. +이 경우 원작 평가와 달리 Tokénmon에서는 restricted로 올려야 할 수 있다. + +즉 restricted는 “포켓몬 본가 메타의 명성”이 아니라, **현재 Tokénmon 엔진 기준 실전 영향력**으로 결정한다. + +## 운영 원칙 + +- `legendary`, `mythical` 태그는 기존 데이터 의미를 유지한다. +- `restricted`는 온라인 PvP 전용 정책 데이터로 관리한다. +- restricted 목록은 **세대별로 따로 관리**한다. +- 초기에는 시드 리스트로 시작하고, 실제 플레이 데이터를 보며 조정한다. + +## 현재 정책 요약 + +| 규칙 | 값 | +|---|---| +| legendary + mythical 총합 | 최대 2 | +| restricted | 최대 1 | +| mythical | 허용 | +| restricted 관리 단위 | 세대별 | +| restricted 의미 | 온라인 PvP 밸런스 태그 | + +## 다음 문서 + +- [세대별 ruleset 설계](./generation-rules.md) +- [치트 대응 정책](../security/anti-cheat.md) diff --git a/docs/pvp/implementation/README.md b/docs/pvp/implementation/README.md new file mode 100644 index 00000000..fcf9c954 --- /dev/null +++ b/docs/pvp/implementation/README.md @@ -0,0 +1,28 @@ +# PvP 구현 계획 문서 + +상위 문서: [PvP 문서 인덱스](../README.md) + +이 섹션은 Tokénmon 온라인 PvP 설계를 **실제 구현 가능한 작업 단위**로 바꾸는 문서 모음이다. + +## 포함 문서 + +1. [PvP 초기 구현 PRD](./prd.md) +2. [PvP 작업 분해 / TODO](./todo-breakdown.md) +3. [서버 패키지 / 모듈 구조 제안](./server-package-layout.md) +4. [PvP 구현 이슈 분해](./issues/README.md) + +## 읽는 순서 + +- 먼저 [PvP 초기 구현 PRD](./prd.md)에서 목표, 비목표, 성공 기준을 본다. +- 다음 [PvP 작업 분해 / TODO](./todo-breakdown.md)에서 실제 개발 순서와 작업 단위를 본다. +- 그 다음 [서버 패키지 / 모듈 구조 제안](./server-package-layout.md)으로 실제 `src/server` 경계를 어떻게 나눌지 본다. +- 마지막으로 [PvP 구현 이슈 분해](./issues/README.md)에서 executor에게 바로 넘길 수 있는 실제 구현 단위를 확인한다. + + +## 구현 시 먼저 볼 상세 계약 + +- Phase 1 등록 작업 전에는 [온라인 파티 등록 상세 계약](../server/contracts/party-registration.md)을 먼저 읽는다. +- Phase 2 룸 작업 전에는 [친구전 룸 / 매치 성립 상세 계약](../server/contracts/room-and-match.md)을 먼저 읽는다. +- Phase 3 배틀 세션 작업 전에는 [실시간 배틀 세션 상세 계약](../server/contracts/realtime-battle-session.md)을 먼저 읽는다. +- 실제 구현 착수 전에는 [PvP 구현 이슈 분해](./issues/README.md)에서 현재 이슈의 범위와 완료 조건을 먼저 확인한다. +- Phase 3 클라이언트 통합은 `session-store` → `client-protocol` → `websocket-client` 순서로 올라간다. diff --git a/docs/pvp/implementation/issues/ISSUE-01-ruleset-foundation.md b/docs/pvp/implementation/issues/ISSUE-01-ruleset-foundation.md new file mode 100644 index 00000000..61a7aa77 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-01-ruleset-foundation.md @@ -0,0 +1,77 @@ +# ISSUE-01 · ruleset / 정책 기반 계층 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +관련 문서: [PvP 초기 구현 PRD](../prd.md), [세대별 ruleset 설계](../../game-design/generation-rules.md), [전설 / 환상 / restricted 정책](../../game-design/special-pokemon-policy.md), [온라인 파티 등록 상세 계약](../../server/contracts/party-registration.md) + +## 목표 + +온라인 PvP에서 사용할 **세대별 ruleset / restricted 정책 / 레벨 압축 규칙**을 코드로 고정한다. + +이 이슈가 끝나면 서버는 최소한 다음 질문에 답할 수 있어야 한다. + +- `gen4`의 현재 PvP ruleset key는 무엇인가? +- 이 세대에서 special limit는 무엇인가? +- 어떤 species가 restricted인가? +- 실제 레벨을 PvP 계산용 유효 레벨로 어떻게 압축하는가? + +## 구현 범위 + +### 신규 모듈 + +- `src/server/rules/ruleset-types.ts` +- `src/server/rules/level-compression.ts` +- `src/server/rules/restricted-species.ts` +- `src/server/rules/ruleset-repository.ts` +- `src/server/rules/ruleset-service.ts` + +### 테스트 + +- `test/pvp-ruleset.test.ts` + +## 핵심 책임 + +1. generation별 active ruleset 정의 +2. `rulesetKey` 형식 고정 +3. legendary / mythical / restricted limit 값 제공 +4. restricted species seed 데이터 조회 +5. `soft-cap-after-50-v1` 계산 함수 구현 +6. `effectiveLevelCap = 60` 보장 + +## 비범위 + +- HTTP endpoint +- DB 저장 +- 온라인 파티 검증 전체 +- 치트 오염 판정 전체 +- 룸 / 배틀 / WebSocket + +## 완료 조건 + +- `getRulesetByGeneration('gen4')` 같은 API가 동작한다. +- ruleset summary가 계약 문서의 주요 필드를 재현한다. +- restricted species 조회가 generation / ruleset 기준으로 가능하다. +- 레벨 압축 계산이 테스트로 고정된다. + +## 테스트 시나리오 + +- `gen4` ruleset 조회 성공 +- 미지원 generation 조회 실패 +- restricted 목록에 특정 species 포함 여부 판정 성공 +- 레벨 1, 50, 51, 60, 72, 100 케이스의 effective level 계산 검증 +- 어떤 실제 레벨도 effective level 60을 넘지 않음 + +## 다음 이슈에 넘기는 계약 + +ISSUE-02는 이 이슈가 제공하는 값을 그대로 사용한다. + +- `rulesetKey` +- `specialLimits` +- `battlePolicy` +- `levelPolicy` +- `isRestrictedSpecies(...)` +- `computeEffectiveLevel(...)` + +## 권장 구현 메모 + +- 초기 버전은 DB보다 **정적 정책 데이터 + repository 인터페이스**로 시작해도 된다. +- 이후 DB가 들어와도 `ruleset-service.ts`의 public contract는 유지하는 방향이 좋다. diff --git a/docs/pvp/implementation/issues/ISSUE-02-party-validation-domain.md b/docs/pvp/implementation/issues/ISSUE-02-party-validation-domain.md new file mode 100644 index 00000000..e59561af --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-02-party-validation-domain.md @@ -0,0 +1,59 @@ +# ISSUE-02 · 온라인 파티 검증 도메인 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-01 · ruleset / 정책 기반 계층](./ISSUE-01-ruleset-foundation.md) +관련 문서: [온라인 파티 등록 상세 계약](../../server/contracts/party-registration.md), [치트 대응 정책](../../security/anti-cheat.md) + +## 목표 + +클라이언트가 보낸 로컬 파티 후보를 서버 관점의 **온라인 등록 가능 / 불가** 결과로 판정하는 순수 도메인 로직을 만든다. + +## 구현 범위 + +### 신규 모듈 + +- `src/server/parties/party-types.ts` +- `src/server/parties/party-validator.ts` +- `src/server/parties/growth-proof.ts` + +### 테스트 + +- `test/pvp-party-validator.test.ts` + +## 핵심 책임 + +1. 파티 슬롯 수 검증 +2. 중복 종 금지 검증 +3. legendary + mythical 총 2 제한 검증 +4. restricted 최대 1 제한 검증 +5. moves / slot / speciesId 기본 입력 검증 +6. growth proof의 member 매칭 검증 +7. 치트 오염 플래그 기본 거부 +8. `levelActual` → `levelEffective` 계산 반영 + +## 비범위 + +- active snapshot 저장 +- HTTP route wiring +- DB transaction +- 룸 생성 + +## 완료 조건 + +- validator가 성공 시 **정규화된 서버용 party snapshot 초안**을 돌려준다. +- 실패 시 안정 에러 코드 목록을 돌려준다. +- ruleset 변경 없이 validator만으로 대부분의 등록 실패 이유를 설명할 수 있다. + +## 테스트 시나리오 + +- 6마리 정상 파티 승인 +- 중복 종 거부 +- legendary/mythical 총량 초과 거부 +- restricted 2마리 거부 +- cheat flagged save 거부 +- growth proof slot mismatch 거부 +- levelEffective 계산값 포함 + +## 다음 이슈에 넘기는 계약 + +ISSUE-03은 이 validator를 감싸서 `PUT /active`에 연결한다. diff --git a/docs/pvp/implementation/issues/ISSUE-03-party-registration-surface.md b/docs/pvp/implementation/issues/ISSUE-03-party-registration-surface.md new file mode 100644 index 00000000..7c3fb4d3 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-03-party-registration-surface.md @@ -0,0 +1,53 @@ +# ISSUE-03 · 온라인 파티 등록 API / 저장 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-01 · ruleset / 정책 기반 계층](./ISSUE-01-ruleset-foundation.md), [ISSUE-02 · 온라인 파티 검증 도메인](./ISSUE-02-party-validation-domain.md) +관련 문서: [온라인 파티 등록 상세 계약](../../server/contracts/party-registration.md), [서버 데이터 모델](../../server/storage/data-model.md) + +## 목표 + +ruleset 조회와 활성 온라인 파티 조회/등록을 실제 서버 surface로 노출한다. + +## 구현 범위 + +### 신규 모듈 + +- `src/server/http/pvp-rules-routes.ts` +- `src/server/http/pvp-party-routes.ts` +- `src/server/parties/party-registration-service.ts` +- `src/server/parties/party-snapshot-repository.ts` + +### 진입점 후보 + +- `src/server/index.ts` +- `src/cli/pvp-server.ts` + +### 테스트 + +- `test/pvp-party-registration-service.test.ts` +- `test/pvp-routes.test.ts` + +## 핵심 책임 + +1. `GET /api/pvp/rulesets/{generation}` +2. `GET /api/pvp/parties/{generation}/active` +3. `PUT /api/pvp/parties/{generation}/active` +4. 세대당 활성 파티 1개 교체 규칙 적용 +5. snapshot version 증가 +6. 실패 시 계약 문서의 error envelope 반환 + +## 비범위 + +- 룸 생성/참가 +- WebSocket +- 배틀 계산 + +## 완료 조건 + +- 등록 요청이 validator를 통과하면 active snapshot이 교체된다. +- ruleset과 active snapshot 조회가 모두 동작한다. +- 등록 실패 시 UI가 소비 가능한 에러 코드가 나온다. + +## 다음 이슈에 넘기는 계약 + +ISSUE-04/05는 여기서 확정된 `snapshotId`, `rulesetKey`, `generation`을 룸 생성 시 참조한다. diff --git a/docs/pvp/implementation/issues/ISSUE-04-room-domain-and-persistence.md b/docs/pvp/implementation/issues/ISSUE-04-room-domain-and-persistence.md new file mode 100644 index 00000000..5aa93ebd --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-04-room-domain-and-persistence.md @@ -0,0 +1,43 @@ +# ISSUE-04 · 친구전 룸 도메인 / 저장 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-03 · 온라인 파티 등록 API / 저장](./ISSUE-03-party-registration-surface.md) +관련 문서: [친구전 룸 / 매치 성립 상세 계약](../../server/contracts/room-and-match.md), [서버 데이터 모델](../../server/storage/data-model.md) + +## 목표 + +친구전 룸의 상태 전이와 저장 모델을 먼저 고정한다. + +## 구현 범위 + +### 신규 모듈 + +- `src/server/rooms/room-types.ts` +- `src/server/rooms/room-code.ts` +- `src/server/rooms/room-validator.ts` +- `src/server/rooms/room-repository.ts` +- `src/server/rooms/room-service.ts` + +### 테스트 + +- `test/pvp-room-service.test.ts` + +## 핵심 책임 + +1. 룸 코드 생성 +2. host / guest 결합 +3. generation / ruleset mismatch 차단 +4. active snapshot 존재 검증 +5. `waiting_for_opponent` → `awaiting_presence` 상태 전이 +6. 배틀 시작 시 사용할 ruleset snapshot freeze 준비 + +## 비범위 + +- 실제 HTTP route +- 실시간 presence +- 배틀 command loop + +## 완료 조건 + +- 룸 상태 머신이 순수 서비스 레벨에서 검증된다. +- 동일 플레이어 중복 참가 같은 기본 오류를 차단한다. diff --git a/docs/pvp/implementation/issues/ISSUE-05-room-http-and-readiness.md b/docs/pvp/implementation/issues/ISSUE-05-room-http-and-readiness.md new file mode 100644 index 00000000..0ea88bb3 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-05-room-http-and-readiness.md @@ -0,0 +1,36 @@ +# ISSUE-05 · 룸 API / 매치 성립 흐름 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-04 · 친구전 룸 도메인 / 저장](./ISSUE-04-room-domain-and-persistence.md) +관련 문서: [친구전 룸 / 매치 성립 상세 계약](../../server/contracts/room-and-match.md) + +## 목표 + +룸 생성/참가/대기 상태 조회를 실제 서버 surface로 연결한다. + +## 구현 범위 + +### 신규 모듈 + +- `src/server/http/pvp-room-routes.ts` +- `src/server/projection/room-projection.ts` + +### 테스트 + +- `test/pvp-room-routes.test.ts` + +## 핵심 책임 + +1. 룸 생성 API +2. 룸 참가 API +3. 대기 상태 조회/표시용 projection +4. 양 플레이어 binding 완료 후 `awaiting_presence` 상태 진입 + +## 비범위 + +- WebSocket presence handshake +- 턴 처리 + +## 완료 조건 + +- 두 플레이어가 룸 코드만으로 배틀 직전 상태까지 도달할 수 있다. diff --git a/docs/pvp/implementation/issues/ISSUE-06-battle-session-domain.md b/docs/pvp/implementation/issues/ISSUE-06-battle-session-domain.md new file mode 100644 index 00000000..36a273a2 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-06-battle-session-domain.md @@ -0,0 +1,42 @@ +# ISSUE-06 · 서버 권한 배틀 세션 코어 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-05 · 룸 API / 매치 성립 흐름](./ISSUE-05-room-http-and-readiness.md) +관련 문서: [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md), [서버 아키텍처](../../server/architecture.md) + +## 목표 + +서버가 턴 진행의 최종 권위자가 되는 battle session 계층을 만든다. + +## 구현 범위 + +### 신규 모듈 + +- `src/server/battle/battle-types.ts` +- `src/server/battle/battle-engine-adapter.ts` +- `src/server/battle/battle-command-service.ts` +- `src/server/battle/battle-turn-service.ts` +- `src/server/battle/battle-session-service.ts` +- `src/server/battle/battle-event-log.ts` + +### 테스트 + +- `test/pvp-battle-session.test.ts` + +## 핵심 책임 + +1. 배틀 시작 snapshot 생성 +2. 턴 수집 / resolve 흐름 +3. move / switch / replacement / forfeit 처리 +4. public/private payload 분리 +5. faint 후 replacement phase 진입 +6. 종료 / 승패 기록 + +## 비범위 + +- 실제 WebSocket 연결 관리 +- 재접속 복원 + +## 완료 조건 + +- 네트워크 계층 없이도 battle session 서비스 단위 테스트가 가능하다. diff --git a/docs/pvp/implementation/issues/ISSUE-07-realtime-command-gateway.md b/docs/pvp/implementation/issues/ISSUE-07-realtime-command-gateway.md new file mode 100644 index 00000000..9d814804 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-07-realtime-command-gateway.md @@ -0,0 +1,34 @@ +# ISSUE-07 · 실시간 명령 게이트웨이 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-06 · 서버 권한 배틀 세션 코어](./ISSUE-06-battle-session-domain.md) +관련 문서: [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +클라이언트가 WebSocket으로 명령을 보내고, 서버가 플레이어별 가시 상태만 내려주는 실시간 transport 계층을 완성한다. + +## 구현 범위 + +### 신규 모듈 + +- `src/server/ws/pvp-ws-server.ts` +- `src/server/ws/connection-registry.ts` +- `src/server/ws/message-router.ts` +- `src/server/ws/heartbeat.ts` + +### 테스트 + +- `test/pvp-ws-gateway.test.ts` + +## 핵심 책임 + +1. room/battle 단위 연결 식별 +2. `battle.command` 라우팅 +3. command accepted / turn resolved push +4. 플레이어별 private payload 분리 전달 +5. heartbeat / disconnect 감지 + +## 완료 조건 + +- 클라이언트는 계산 결과가 아니라 **서버 이벤트**만 받아 렌더링한다. diff --git a/docs/pvp/implementation/issues/ISSUE-08-reconnect-and-ops.md b/docs/pvp/implementation/issues/ISSUE-08-reconnect-and-ops.md new file mode 100644 index 00000000..2f1b8c65 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-08-reconnect-and-ops.md @@ -0,0 +1,36 @@ +# ISSUE-08 · 재접속 / 운영 안정화 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-07 · 실시간 명령 게이트웨이](./ISSUE-07-realtime-command-gateway.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [서버 데이터 모델](../../server/storage/data-model.md) + +## 목표 + +실전 사용 가능한 수준으로 재접속, 타임아웃, 디버깅 로그를 다듬는다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/server/ws/session-resume.ts` +- `src/server/battle/timeout-policy.ts` +- `src/server/projection/battle-projection.ts` +- 운영용 로그/조회 도구 + +### 테스트 + +- `test/pvp-reconnect.test.ts` +- `test/pvp-timeout-policy.test.ts` + +## 핵심 책임 + +1. room snapshot 재구성 +2. 끊김 후 현재 공개 상태 복원 +3. 중복 명령 제출 방지 +4. 타임아웃 패배/자동 처리 정책 +5. 사후 디버깅 가능한 이벤트 로그 조회 + +## 완료 조건 + +- 사용자가 연결이 잠깐 끊겨도 배틀을 이어갈 수 있다. +- 운영자가 문제 배틀을 로그로 추적할 수 있다. diff --git a/docs/pvp/implementation/issues/ISSUE-09-client-session-store.md b/docs/pvp/implementation/issues/ISSUE-09-client-session-store.md new file mode 100644 index 00000000..f58960ae --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-09-client-session-store.md @@ -0,0 +1,34 @@ +# ISSUE-09 · 클라이언트 배틀 세션 스토어 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-08 · 재접속 / 운영 안정화](./ISSUE-08-reconnect-and-ops.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +서버 authoritative 이벤트 스트림을 Claude Code / TUI가 소비할 수 있도록, **순수 함수 기반 클라이언트 상태 저장소**를 만든다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/session-store.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-session-store.test.ts` + +## 핵심 책임 + +1. `room.snapshot`으로 로컬 세션 상태를 부트스트랩한다. +2. `battle.request_action`, `battle.force_replacement`를 입력 가능 상태로 투영한다. +3. `battle.command_accepted`, `battle.command_rejected`에 따라 로컬 입력 잠금 상태를 갱신한다. +4. `battle.turn_resolved`, `battle.ended`를 반영해 pending command / request를 정리한다. +5. UI가 raw envelope 조립을 몰라도 되도록 `battle.command` 생성을 thin wrapper로 제공한다. + +## 완료 조건 + +- 클라이언트가 서버 이벤트만으로 현재 visible battle state를 복원할 수 있다. +- 중복 제출 / phase mismatch 시도를 막기 위한 로컬 가드가 존재한다. +- 이후 transport adapter나 TUI 레이어가 이 저장소를 그대로 재사용할 수 있다. diff --git a/docs/pvp/implementation/issues/ISSUE-10-client-protocol-adapter.md b/docs/pvp/implementation/issues/ISSUE-10-client-protocol-adapter.md new file mode 100644 index 00000000..23cc2810 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-10-client-protocol-adapter.md @@ -0,0 +1,34 @@ +# ISSUE-10 · 클라이언트 프로토콜 어댑터 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-09 · 클라이언트 배틀 세션 스토어](./ISSUE-09-client-session-store.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +클라이언트가 websocket 라이브러리나 UI 프레임워크에 종속되지 않으면서도, **서버 outbound envelope를 소비하고 필요한 outbound message를 생성하는 protocol adapter**를 만든다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/client-protocol.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-client-protocol.test.ts` + +## 핵심 책임 + +1. 서버 outbound envelope(`battle.*`, `ws.ping`, `ws.error`)를 단일 진입점으로 처리한다. +2. 배틀 이벤트는 `session-store`에 위임해 authoritative 상태를 갱신한다. +3. `ws.ping` 수신 시 `ws.pong` outbound envelope를 생성한다. +4. transport 오류(`ws.error`)를 UI가 읽을 수 있는 상태로 기록한다. +5. UI 레이어가 raw protocol을 몰라도 되도록 `battle.command` 생성을 래핑한다. + +## 완료 조건 + +- 상위 UI는 `applyPvpTransportEnvelope`와 `createPvpClientCommand`만으로 프로토콜 왕복을 다룰 수 있다. +- adapter가 socket 구현체 없이도 테스트 가능하다. +- 이후 실제 websocket 연결기나 Claude Code command loop는 이 adapter 위에 얹을 수 있다. diff --git a/docs/pvp/implementation/issues/ISSUE-11-websocket-client-connector.md b/docs/pvp/implementation/issues/ISSUE-11-websocket-client-connector.md new file mode 100644 index 00000000..aa4e5b09 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-11-websocket-client-connector.md @@ -0,0 +1,43 @@ +# ISSUE-11 · WebSocket 클라이언트 커넥터 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-10 · 클라이언트 프로토콜 어댑터](./ISSUE-10-client-protocol-adapter.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +순수 `session-store` / `client-protocol` 위에 **실제 WebSocket 연결 수명주기와 송수신을 얹는 클라이언트 커넥터**를 만든다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/websocket-client.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-websocket-client.test.ts` + +## 핵심 책임 + +1. `roomId` / `token` / 서버 URL을 받아 PvP WebSocket 연결을 열고 종료한다. +2. raw socket inbound message를 `client-protocol` 단일 진입점으로 전달한다. +3. UI가 socket 구현체를 몰라도 되도록 `connect`, `disconnect`, `sendBattleCommand`, `subscribe` 표면을 제공한다. +4. 브라우저 전역 `WebSocket`에 고정되지 않도록 socket factory / constructor 주입 경계를 제공한다. +5. `connecting`, `connected`, `reconnecting`, `closed`, `error` 같은 transport 상태를 별도 모델로 노출한다. +6. `ws.ping` / `ws.pong`, close code, parse failure, duplicate connect 같은 transport 오류를 battle state와 분리해 다룬다. + +## 설계 메모 + +- 이 이슈는 **배틀 계산**이 아니라 **transport 연결기**를 만드는 단계다. 서버 authoritative 원칙은 그대로 유지한다. +- 재접속 UX 전체를 완성하는 것은 [ISSUE-08 · 재접속 / 운영 안정화](./ISSUE-08-reconnect-and-ops.md)와 연결되지만, 클라이언트 측에서는 우선 “끊김 감지 / 재연결 시도 훅 / snapshot 재부트스트랩”을 넣을 수 있는 최소 경계를 먼저 만든다. +- Claude Code와 battle TUI가 같은 connector를 재사용할 수 있도록, 터미널 입출력과 WebSocket transport를 직접 결합하지 않는다. +- 따라서 이 단계의 핵심은 “socket 이벤트 핸들러를 여기저기 흩뿌리는 것”이 아니라, **환경 독립 transport 경계**를 먼저 세우는 것이다. + +## 완료 조건 + +- 상위 UI는 WebSocket 라이브러리의 이벤트 이름을 직접 알지 않고도 PvP 서버에 접속할 수 있다. +- fake socket으로 연결/메시지/종료 시나리오를 독립 테스트할 수 있다. +- `client-protocol` / `session-store` 테스트와 분리된 transport 테스트가 가능하다. +- 이후 Claude Code command loop나 battle TUI는 이 connector 위에 얹는 방식으로 실시간 대전을 붙일 수 있다. diff --git a/docs/pvp/implementation/issues/ISSUE-12-client-reconnect-controller.md b/docs/pvp/implementation/issues/ISSUE-12-client-reconnect-controller.md new file mode 100644 index 00000000..75e441a5 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-12-client-reconnect-controller.md @@ -0,0 +1,42 @@ +# ISSUE-12 · 클라이언트 재접속 / backoff 컨트롤러 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-11 · WebSocket 클라이언트 커넥터](./ISSUE-11-websocket-client-connector.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [ISSUE-08 · 재접속 / 운영 안정화](./ISSUE-08-reconnect-and-ops.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +`websocket-client` 위에 **환경 독립 reconnect / backoff orchestration 레이어**를 올려, Claude Code / TUI가 예기치 않은 끊김 이후에도 authoritative snapshot 기반으로 다시 이어붙일 수 있게 한다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/reconnect-controller.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-reconnect-controller.test.ts` + +## 핵심 책임 + +1. manual disconnect와 예기치 않은 close/error를 구분한다. +2. 예기치 않은 끊김 뒤에는 설정 가능한 backoff 정책으로 재접속을 스케줄링한다. +3. 재접속 시도 횟수, 다음 대기 시간, 마지막 reconnect 사유를 UI가 읽을 수 있는 상태로 노출한다. +4. 기존 `session-store` / `client-protocol` / `websocket-client` 책임을 침범하지 않고 orchestration만 담당한다. +5. fake timer / fake scheduler로 deterministic test가 가능하도록 시간 의존성을 주입 가능하게 만든다. + +## 설계 메모 + +- authoritative state 복구는 여전히 서버 `room.snapshot`이 담당한다. +- 이 레이어는 “무엇을 다시 그릴지”를 계산하지 않고, “언제 다시 붙을지”만 결정한다. +- 초기 PvP는 battle animation 재생보다 **빠른 재동기화와 입력 잠금 해제**가 더 중요하다. +- 따라서 reconnect controller는 transport wrapper이지, 별도 battle state store가 아니다. + +## 완료 조건 + +- 예기치 않은 close 뒤에 controller가 자동으로 reconnect를 스케줄링할 수 있다. +- 사용자가 명시적으로 종료한 경우에는 추가 reconnect가 발생하지 않는다. +- backoff 정책과 시도 횟수가 테스트에서 deterministic하게 검증된다. +- 이후 Claude Code command loop / battle TUI는 이 controller 위에서 “재연결 중” UX만 얹으면 된다. diff --git a/docs/pvp/implementation/issues/ISSUE-13-session-client-orchestrator.md b/docs/pvp/implementation/issues/ISSUE-13-session-client-orchestrator.md new file mode 100644 index 00000000..171f98cf --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-13-session-client-orchestrator.md @@ -0,0 +1,50 @@ +# ISSUE-13 · 상위 PvP session client orchestration 레이어 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-12 · 클라이언트 재접속 / backoff 컨트롤러](./ISSUE-12-client-reconnect-controller.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +`websocket-client`와 `reconnect-controller`를 조합해, Claude Code / battle TUI / 이후 CLI surface가 그대로 붙일 수 있는 **상위 PvP session client 단일 진입점**을 만든다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/session-client.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-session-client.test.ts` + +## 핵심 책임 + +1. 내부에서 WebSocket transport client와 reconnect controller를 생성/조합한다. +2. 상위 consumer가 `connect`, `disconnect`, `dispose`, `subscribe`, `getState`, `sendBattleCommand` 만으로 세션을 다룰 수 있게 한다. +3. UI가 nested 내부 구조를 매번 더듬지 않도록 다음 정보를 파생 상태로 평평하게 노출한다. + - transport status + - session / protocol snapshot + - reconnect scheduling 메타 + - 현재 pending request 존재 여부와 종류 + - 현재 command 입력 가능 여부 +4. authoritative battle 계산은 여전히 서버가 담당하고, 이 레이어는 **상태 조합과 읽기 모델 정리**만 맡는다. +5. 이후 battle TUI / Claude Code command loop wiring이 이 레이어 위에서 시작될 수 있도록 최소하지만 안정적인 진입점을 제공한다. + +## 설계 메모 + +- `session-store`는 battle-visible state 규칙을 관리한다. +- `client-protocol`은 inbound/outbound envelope 해석을 담당한다. +- `websocket-client`는 transport 수명주기와 raw 송수신을 담당한다. +- `reconnect-controller`는 예기치 않은 끊김 뒤 재접속 스케줄링을 담당한다. +- 따라서 이번 이슈는 새로운 계산 계층이 아니라, **상위 consumer용 orchestration / facade 계층**을 만드는 단계다. + +즉, 이후 상위 UI는 “socket 이벤트 + reconnect 이벤트 + protocol state + command lock”을 각각 따로 구독하지 않고, `session-client` 하나만 보면 된다. + +## 완료 조건 + +- 상위 consumer가 단일 객체만으로 PvP 연결 lifecycle을 다룰 수 있다. +- 파생 상태에서 `canSendCommand`, `hasPendingRequest`, `activeRequestKind`, reconnect metadata를 곧바로 읽을 수 있다. +- connect → snapshot → action request → command submit → reconnect 시나리오가 독립 테스트로 검증된다. +- 이후 battle TUI / Claude Code 명령 루프는 이 레이어 위에 UX만 추가하면 된다. diff --git a/docs/pvp/implementation/issues/ISSUE-14-action-request-view.md b/docs/pvp/implementation/issues/ISSUE-14-action-request-view.md new file mode 100644 index 00000000..b38e709d --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-14-action-request-view.md @@ -0,0 +1,42 @@ +# ISSUE-14 · PvP action request rendering / input UX adapter + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-13 · 상위 PvP session client orchestration 레이어](./ISSUE-13-session-client-orchestrator.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +`session-client`가 노출하는 authoritative session 상태 위에, Claude Code / battle TUI / 이후 CLI surface가 공통으로 사용할 수 있는 **action request 전용 읽기 모델 / 입력 UX adapter**를 만든다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/action-request-view.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-action-request-view.test.ts` + +## 핵심 책임 + +1. 현재 pending request(`choose_move_or_switch`, `choose_replacement`)를 UI가 곧바로 그릴 수 있는 메뉴/섹션 모델로 변환한다. +2. move / switch / replacement / forfeit 입력 후보를 **서버 authoritative command shape**에 맞춘 command option으로 노출한다. +3. `commandSubmitted`, `canSendCommand`, request kind 같은 상태를 바탕으로 “지금 입력 가능한가”, “이미 제출했는가”를 UX-friendly 상태로 정리한다. +4. TUI/CLI가 raw `pendingRequest` 구조를 직접 해석하지 않도록, 라벨/설명/기본 hotkey/sort 순서를 이 레이어에서 정리한다. +5. transport, reconnect, battle calculation 책임은 침범하지 않고 **표현용 파생 상태와 입력 후보 정리**만 담당한다. + +## 설계 메모 + +- `session-store`는 authoritative pending request와 command lock 상태를 보존한다. +- `session-client`는 transport / reconnect / protocol / session 상태를 한 곳으로 묶는다. +- 따라서 이번 이슈는 새로운 store를 추가하는 것이 아니라, **상위 UX가 소비할 마지막 얇은 adapter 레이어**를 만드는 단계다. +- 초기 범위에서는 실제 키 입력 루프 전체를 battle TUI에 직접 연결하지 않는다. 대신, 후속 이슈가 그대로 붙일 수 있도록 deterministic한 view model을 먼저 확정한다. + +## 완료 조건 + +- 상위 consumer가 `session-client` 상태 하나만으로 현재 요청 화면을 렌더할 수 있다. +- action / replacement phase 각각에 대해 move, switch, replacement, forfeit 입력 후보가 테스트로 검증된다. +- 이미 제출된 턴과 입력 불가 상태가 별도 UX 상태로 노출된다. +- 이후 battle TUI / Claude Code command loop는 이 adapter 위에 실제 키 바인딩과 출력만 얹으면 된다. diff --git a/docs/pvp/implementation/issues/ISSUE-15-turn-result-view.md b/docs/pvp/implementation/issues/ISSUE-15-turn-result-view.md new file mode 100644 index 00000000..5d7387f0 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-15-turn-result-view.md @@ -0,0 +1,57 @@ +# ISSUE-15 · PvP turn resolved rendering / 결과 로그 UX adapter + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-14 · PvP action request rendering / input UX adapter](./ISSUE-14-action-request-view.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +`battle.turn_resolved` authoritative payload와 `session-client`가 보유한 session 상태를 바탕으로, Claude Code / battle TUI / 이후 CLI surface가 그대로 사용할 수 있는 **턴 결과 전용 읽기 모델 / 결과 로그 UX adapter**를 만든다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/turn-result-view.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-turn-result-view.test.ts` + +## 핵심 책임 + +1. `battle.turn_resolved` payload를 그대로 받아, 결과 로그/요약 정보를 UI가 곧바로 그릴 수 있는 deterministic view model로 변환한다. +2. 다음 이벤트를 **한국어 라벨/메시지**로 정리한다. + - `move_used` + - `switch_used` + - `damage_applied` + - `heal_applied` + - `status_applied` + - `pokemon_fainted` + - `replacement_selected` + - `forfeit` +3. `postTurnVisibleState`를 바탕으로 다음 요약 필드를 노출한다. + - 내 active 포켓몬 라벨 + - 상대 active 포켓몬 라벨 + - 내 벤치 잔여/기절 정보 + - 상대 벤치 비공개 잔여 수 요약 + - `nextPhase` / `status` / terminal 결과 라벨 +4. `session-client` consumer가 가능하면 **session state 하나만 보고도** 마지막 턴 결과 summary를 붙일 수 있도록 session-state wrapper API를 함께 제공한다. +5. transport, reconnect, battle calculation 책임은 침범하지 않고 **표현용 adapter**로만 머문다. + +## 설계 메모 + +- payload 기반 API인 `createPvpTurnResultViewFromPayload(...)`는 **가장 정보가 많은 authoritative 입력**을 받아 로그와 summary를 모두 만든다. +- wrapper API인 `createPvpTurnResultView(state, payload?)`는 두 가지 용도를 가진다. + - `payload`가 있으면: 로그 + summary를 함께 만든다. + - `payload`가 없으면: session state에 남아 있는 `visibleState`, `lastResolvedTurn`, `battleStatus`, `terminalResult`만으로 **summary-only view**를 만든다. +- 현재 `session-store`는 raw `battle.turn_resolved.events` 자체를 보존하지 않으므로, **완전한 로그 재구성은 payload가 있을 때만 가능**하다. +- 따라서 이 이슈는 session-store를 확장하지 않고도 붙일 수 있는 최소/안정적인 UX adapter를 먼저 확정하는 단계다. + +## 완료 조건 + +- 상위 consumer가 payload만으로 턴 결과 화면/로그를 바로 렌더할 수 있다. +- 상위 consumer가 session state만으로도 마지막 턴 summary를 렌더할 수 있다. +- 기본 액션 턴, 교체 유도 턴, finished 턴, null 입력 처리 시나리오가 테스트로 검증된다. +- 이후 battle TUI / Claude Code command loop는 이 adapter 위에 출력 레이아웃만 얹으면 된다. diff --git a/docs/pvp/implementation/issues/ISSUE-16-command-status-view.md b/docs/pvp/implementation/issues/ISSUE-16-command-status-view.md new file mode 100644 index 00000000..8f976686 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-16-command-status-view.md @@ -0,0 +1,49 @@ +# ISSUE-16 · PvP submitted-command / acceptance-status 읽기 모델 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-13 · 상위 PvP session client orchestration 레이어](./ISSUE-13-session-client-orchestrator.md), [ISSUE-14 · PvP action request rendering / input UX adapter](./ISSUE-14-action-request-view.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +`battle.command_accepted`, `pendingCommand`, `lastRejectedCommand`, `pendingRequest.commandSubmitted` 사이의 관계를 상위 consumer가 다시 해석하지 않도록, `session-client` 상태에서 바로 읽을 수 있는 **submitted-command / acceptance-status 전용 한국어 view model**을 추가한다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/command-status-view.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-command-status-view.test.ts` + +## 핵심 책임 + +1. `session state`만으로 이번 턴 제출 상태를 `created / accepted / rejected_permanent / none` 으로 정규화한다. +2. 제출한 명령을 다음 한국어 요약으로 노출한다. + - `기술 n번` + - `교체 슬롯 n` + - `replacement 슬롯 n` + - `항복` +3. 상위 consumer가 그대로 사용할 수 있도록 다음 필드를 deterministic하게 제공한다. + - 잠금 여부 / 상호작용 가능 여부 + - 사용자용 `statusLabel` / `detailLabel` + - rejection 코드/메시지/재시도 가능 여부 summary + - `request.commandSubmitted` 와 `pendingCommand.status` 관계 설명 요약 +4. transport, store, reconnect 동작은 바꾸지 않고 **표현용 adapter**에만 머문다. + +## 설계 메모 + +- `createPvpCommandStatusViewFromSession(session, options?)` 는 순수 session-state 기반 변환기다. +- `createPvpCommandStatusView(state)` 는 `PvpSessionClientState` wrapper로, 상위 consumer가 이미 들고 있는 session-client snapshot을 그대로 넘기면 된다. +- `request.commandSubmitted=true` 이지만 `pendingCommand`가 없는 snapshot/reconnect 상황은 **accepted fallback** 으로 간주한다. +- retryable rejection은 제출 상태를 `none` 으로 되돌리되, `rejection summary`를 유지해 재제출 UX에 붙일 수 있게 한다. +- permanent rejection은 `pendingCommand.status=rejected_permanent` 와 `lastRejectedCommand` 를 함께 노출해, 재제출 없이 잠금 상태로 보여줄 수 있게 한다. + +## 완료 조건 + +- 상위 consumer가 session-client state 하나로 제출 상태 라벨/요약/거부 사유를 바로 렌더할 수 있다. +- null input, idle state, created, accepted, permanent rejection, snapshot accepted fallback 시나리오가 테스트로 검증된다. +- 이후 battle TUI / Claude Code command loop는 이 adapter 결과를 그대로 붙여 제출 상태 섹션을 만들 수 있다. diff --git a/docs/pvp/implementation/issues/ISSUE-17-session-screen-view.md b/docs/pvp/implementation/issues/ISSUE-17-session-screen-view.md new file mode 100644 index 00000000..7d22be0b --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-17-session-screen-view.md @@ -0,0 +1,56 @@ +# ISSUE-17 · PvP session-level screen 읽기 모델 + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-14 · PvP action request rendering / input UX adapter](./ISSUE-14-action-request-view.md), [ISSUE-15 · PvP turn resolved rendering / 결과 로그 UX adapter](./ISSUE-15-turn-result-view.md), [ISSUE-16 · PvP submitted-command / acceptance-status 읽기 모델](./ISSUE-16-command-status-view.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +기존의 `action-request-view`, `command-status-view`, `turn-result-view` 세 read model을 상위 consumer가 다시 조합하지 않도록, `session-client` snapshot 하나에서 바로 읽을 수 있는 **단일 session-level screen view model**을 추가한다. + +이 이슈는 아직 battle TUI layout 문자열이나 CLI loop 결합까지는 가지 않고, 상위 consumer/Claude Code/TUI가 그대로 소비할 수 있는 deterministic한 **순수 데이터 adapter**에만 집중한다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/session-screen-view.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-session-screen-view.test.ts` + +## 핵심 책임 + +1. transport / reconnect 상태를 한국어 summary label로 정리한다. +2. session 메타(`roomId`, `battleId`, `turn`, `battleStatus`, `generation`, `rulesetKey`, `yourSeat`)를 상위 consumer가 그대로 쓸 수 있는 요약으로 묶는다. +3. 기존 하위 read model 결과를 그대로 포함한다. + - `createPvpActionRequestView(state)` + - `createPvpCommandStatusView(state)` + - `createPvpTurnResultView(state, payload?)` +4. 상위 consumer가 한 번에 분기할 수 있도록 top-level 상태를 정규화한다. + - empty state + - terminal 여부 + - reconnecting / awaiting_input / command_locked 등 화면 상태 +5. transport/store/session-client의 public contract는 바꾸지 않고, **표현용 adapter**로만 해결한다. + +## 설계 메모 + +- `createPvpSessionScreenView(state, payloadOverride?)` 는 순수 함수다. +- turn result는 payload override가 있으면 우선 사용하고, 없으면 session state fallback을 사용한다. +- screen status는 다음 우선순위로 정규화한다. + 1. terminal + 2. reconnecting + 3. awaiting_input + 4. command_locked + 5. transport_wait + 6. idle +- empty state 판단은 **request/result가 모두 비어 있는가** 기준으로 한다. command status는 보조 정보로 유지한다. +- 이후 ISSUE-18 이상에서 TUI renderer / Claude Code command loop는 이 screen view model을 그대로 받아 각 채널에 맞는 layout만 입히면 된다. + +## 완료 조건 + +- 상위 consumer가 `PvpSessionClientState` 하나만으로 세부 하위 read model과 transport/session 요약을 함께 소비할 수 있다. +- null state, reconnecting empty state, command locked state, terminal fallback 시나리오가 테스트로 검증된다. +- battle-tui/cli loop를 수정하지 않아도, 다음 슬라이스에서 붙일 수 있는 안정적인 상위 view contract가 생긴다. diff --git a/docs/pvp/implementation/issues/ISSUE-18-session-screen-renderer.md b/docs/pvp/implementation/issues/ISSUE-18-session-screen-renderer.md new file mode 100644 index 00000000..ece6cc17 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-18-session-screen-renderer.md @@ -0,0 +1,52 @@ +# ISSUE-18 · deterministic PvP session terminal renderer + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-17 · PvP session-level screen 읽기 모델](./ISSUE-17-session-screen-view.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +`createPvpSessionScreenView(state, payloadOverride?)` 결과를 battle-tui나 Claude Code 명령 루프가 바로 소비할 수 있도록, **ANSI 없이도 테스트 가능한 deterministic plain-text terminal renderer**를 추가한다. + +이 이슈는 아직 `battle-tui/game-loop`, stdin, WebSocket 입출력 결합까지는 가지 않는다. 오직 `src/pvp` 경계 안에서, 이미 만들어진 상위 screen view contract를 사람이 읽기 쉬운 멀티라인 문자열로 내리는 첫 consumer layer만 만든다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/session-screen-renderer.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-session-screen-renderer.test.ts` + +## 핵심 책임 + +1. `PvpSessionScreenView | null` 을 deterministic한 plain-text layout으로 렌더한다. +2. 상위 consumer 편의를 위해 `PvpSessionClientState | null` 에서 곧바로 화면 문자열을 만드는 wrapper를 함께 제공한다. + - `renderPvpSessionScreen(view)` + - `renderPvpSessionClientScreen(state, payloadOverride?)` +3. 다음 섹션을 고정 순서로 포함한다. + - transport + - session + - action request + - command status + - turn result +4. request/result가 비어 있을 때 placeholder 문구를 일관되게 유지한다. +5. 하위 view model contract는 바꾸지 않고, 표현 계층만 추가한다. + +## 설계 메모 + +- 출력은 한국어 plain-text 멀티라인 문자열이며, ANSI/색상/커서 이동을 포함하지 않는다. +- renderer는 순수 함수이며 외부 I/O에 의존하지 않는다. +- `renderPvpSessionClientScreen` 는 내부에서 `createPvpSessionScreenView` 를 호출하고, 실제 layout 규칙은 `renderPvpSessionScreen` 한 곳에 모은다. +- 메뉴 entry는 항상 같은 순서와 같은 구분자(`|`)를 써서 line-by-line 테스트가 가능해야 한다. +- transport/session/action/request/result 각 섹션은 battle-tui 없이도 상위 consumer가 그대로 출력할 수 있는 최소 정보를 포함한다. +- 이후 ISSUE-19 이상에서 TUI frame/command loop를 붙이더라도, 문자열 레이아웃 contract는 이 renderer를 기준으로 점진 확장한다. + +## 완료 조건 + +- null/empty state, awaiting input, reconnecting, terminal 시나리오가 테스트로 검증된다. +- 상위 consumer가 `session-client` snapshot 하나만 넘겨도 즉시 출력 가능한 deterministic terminal 문자열을 얻는다. +- `src/pvp` 바깥 의존성 없이도 session-level UI first consumer가 준비된다. diff --git a/docs/pvp/implementation/issues/ISSUE-19-session-terminal-controller.md b/docs/pvp/implementation/issues/ISSUE-19-session-terminal-controller.md new file mode 100644 index 00000000..3523b084 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-19-session-terminal-controller.md @@ -0,0 +1,71 @@ +# ISSUE-19 · PvP session terminal controller / input token bridge + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-18 · deterministic PvP session terminal renderer](./ISSUE-18-session-screen-renderer.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +ISSUE-18에서 고정한 deterministic plain-text renderer를 실제 상위 consumer가 바로 붙일 수 있도록, `PvpSessionClient` snapshot과 사용자 입력 토큰 사이를 잇는 **terminal-facing controller layer**를 추가한다. + +이 이슈의 목적은 아직 `battle-tui/game-loop` 전체를 PvP용으로 갈아엎는 것이 아니다. 대신 battle-tui, Claude Code command loop, 이후 CLI entrypoint가 공통으로 재사용할 수 있는 작은 adapter를 먼저 만든다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/session-terminal-controller.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-session-terminal-controller.test.ts` + +## 핵심 책임 + +1. `PvpSessionClientState | null`에서 즉시 출력 가능한 최신 화면 문자열을 제공한다. + - 내부적으로 `renderPvpSessionClientScreen(state, payloadOverride?)`를 사용한다. +2. 현재 pending action request에서 노출한 `inputToken`을 authoritative `BattleCommand`로 역매핑한다. + - `1`, `2`, `switch:n`, `replace:n`, `forfeit` +3. 상위 consumer가 stdin/Claude 응답을 바로 넘길 수 있도록 token submit API를 제공한다. + - 토큰이 유효하면 `sessionClient.sendBattleCommand(...)` 호출 + - 토큰이 없거나 현재 request와 맞지 않으면 deterministic error/result 반환 +4. controller는 transport/store/view model contract를 바꾸지 않고, **session-client + renderer + request-view를 묶는 orchestration adapter**에 머문다. +5. 이후 battle-tui/CLI가 얹기 쉬운 최소 public contract를 만든다. + - 현재 화면 문자열 조회 + - 현재 입력 가능 여부/가능 토큰 조회 + - 토큰 제출 결과 조회 + +## 설계 메모 + +- controller는 ANSI, raw stdin, WebSocket lifecycle, 서버 bootstrapping을 직접 다루지 않는다. +- 입력 해석은 `createPvpActionRequestView(state)`가 이미 제공하는 menu entry의 `inputToken`/`command`를 그대로 신뢰한다. +- 같은 token이라도 현재 pending request가 바뀌면 결과가 바뀔 수 있으므로, 매 호출마다 최신 state snapshot을 기준으로 판단한다. +- 반환 타입은 상위 consumer가 즉시 분기할 수 있게 success / invalid-token / unavailable / transport-not-ready 류의 상태를 명시적으로 담아야 한다. +- 이 레이어는 이후 `battle-tui` 통합 전 단계의 contract 고정이 목적이므로, side effect는 `sessionClient.sendBattleCommand(...)` 한 곳으로 제한한다. + +## 기대 public contract + +- snapshot 조회 + - `screen`: 현재 plain-text terminal screen 문자열 + - `inputEntries`: 현재 request 기준 메뉴 엔트리 + `inputToken` + authoritative `BattleCommand` + - `availableInputTokens`: 실제 제출 가능한 token 목록 +- token 해석 + - `resolved` + - `invalid_token` + - `no_request` + - `locked` + - `transport_not_ready` +- token 제출 + - `submitted` + - `invalid_token` + - `no_request` + - `locked` + - `transport_not_ready` + - `unavailable` + +## 완료 조건 + +- 현재 session-client snapshot 하나만 있으면 terminal consumer가 화면 문자열과 입력 가능 토큰 목록을 모두 얻을 수 있다. +- move/switch/replacement/forfeit token 제출 성공, 잘못된 token, 잠긴 상태, request 없음 시나리오가 테스트로 검증된다. +- `battle-tui` 또는 별도 CLI가 이 controller만 붙여도 PvP 명령 루프의 첫 통합을 시작할 수 있다. diff --git a/docs/pvp/implementation/issues/ISSUE-20-session-terminal-runner.md b/docs/pvp/implementation/issues/ISSUE-20-session-terminal-runner.md new file mode 100644 index 00000000..ffec39cc --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-20-session-terminal-runner.md @@ -0,0 +1,73 @@ +# ISSUE-20 · live PvP session terminal runner + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-19 · PvP session terminal controller / input token bridge](./ISSUE-19-session-terminal-controller.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +ISSUE-19에서 고정한 terminal controller contract 위에, 실제 상위 consumer가 곧바로 붙일 수 있는 **live session terminal runner**를 추가한다. + +이 이슈는 아직 `src/cli` 진입점이나 raw stdin/node readline 구현을 직접 넣는 단계가 아니다. 대신 `PvpSessionClient`의 live state 변화와 `PvpSessionTerminalController`의 input token submit contract를 하나의 작은 orchestration layer로 묶어서, 이후 battle-tui/CLI가 여기에 stdin loop/room join/bootstrap만 얹으면 되도록 만드는 것이 목적이다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/session-terminal-runner.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-session-terminal-runner.test.ts` + +## 핵심 책임 + +1. `PvpSessionClient`의 live state를 구독하고, 최신 `PvpSessionTerminalSnapshot`을 항상 유지한다. +2. 상위 consumer가 즉시 렌더할 수 있는 runner state를 제공한다. + - 최신 `screen` + - 최신 `availableInputTokens` + - 마지막 submit 결과 + - render revision / update sequence 같은 deterministic 갱신 기준 +3. `submitInputToken(token)`을 노출해 controller submit contract를 그대로 재사용한다. +4. session state가 바뀌면 자동으로 새 snapshot을 계산하고 listener에게 전달한다. +5. 이 레이어는 여전히 ANSI/raw stdin/process exit을 직접 다루지 않는다. + +## 설계 메모 + +- runner는 transport lifecycle을 완전히 소유하지 않는다. + - `connect()` / `disconnect()` 같은 session bootstrapping은 상위 entrypoint가 선택한다. + - runner는 기본적으로 `sessionClient.subscribe(...)` 위에서만 동작한다. +- `start()`는 subscribe를 설치하고 즉시 현재 snapshot을 계산한다. +- `stop()`은 구독만 정리하며, process 종료나 stdin cleanup까지 책임지지 않는다. +- 동일한 최신 state를 기준으로 submit한 결과가 있으면, runner state의 `lastSubmitResult`를 갱신하고 listener에게 재방출한다. +- deterministic 테스트를 위해 실제 clock, stdin, stdout 없이도 state transition을 검증할 수 있어야 한다. +- 이후 battle-tui/CLI는 이 runner 위에 다음만 얹으면 된다. + - room join / connect bootstrap + - stdin token 수집 + - screen clear / repaint 정책 + +## 기대 public contract + +- `createPvpSessionTerminalRunner(...)` +- `runner.start()` / `runner.stop()` +- `runner.getState()` +- `runner.subscribe(listener)` +- `runner.submitInputToken(token)` + +runner state는 최소 다음 정보를 포함한다. + +- `running` +- `revision` +- `snapshot` +- `screen` +- `availableInputTokens` +- `lastSubmitResult` + +## 완료 조건 + +- start 직후 최신 snapshot을 한 번 계산하고 listener에게 안정적으로 전달한다. +- session-client state 변경 시 runner가 새 screen/token 목록을 반영한다. +- 유효 token 제출 시 `lastSubmitResult`와 snapshot이 함께 갱신된다. +- stop 이후에는 더 이상 session-client 업데이트를 전파하지 않는다. +- battle-tui/CLI가 raw stdin 없이도 이 runner 하나만으로 live PvP 화면 루프를 시작할 수 있는 상태가 된다. diff --git a/docs/pvp/implementation/issues/ISSUE-21-session-terminal-cli.md b/docs/pvp/implementation/issues/ISSUE-21-session-terminal-cli.md new file mode 100644 index 00000000..1a9d5adc --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-21-session-terminal-cli.md @@ -0,0 +1,70 @@ +# ISSUE-21 · session terminal CLI bootstrap + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-20 · live PvP session terminal runner](./ISSUE-20-session-terminal-runner.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +ISSUE-20에서 고정한 `PvpSessionTerminalRunner` 위에, 실제 live PvP 화면 루프가 곧바로 붙을 수 있는 **CLI bootstrap/orchestration layer**를 추가한다. + +이번 단계의 목적은 `raw stdin/readline + stdout repaint`를 테스트 가능한 추상화 뒤에 배치하는 것이다. 아직 여기서 room create/join UX를 만들거나, WebSocket transport 생성/교체를 직접 담당하지는 않는다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/session-terminal-cli.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-session-terminal-cli.test.ts` + +## 핵심 책임 + +1. 주입받은 `runner`를 기준으로 live CLI 루프의 시작/정지를 orchestration 한다. +2. `connect()` / `disconnect()` bootstrap 훅을 옵션으로 받아 session connect/disconnect를 상위에서 결정할 수 있게 한다. +3. 입력 소스와 화면 출력은 인터페이스로 분리한다. + - 입력 소스는 토큰 스트림만 제공한다. + - 출력은 최신 screen repaint만 책임진다. +4. runner state가 바뀌면 즉시 repaint를 수행한다. +5. 입력 토큰이 들어오면 `runner.submitInputToken(token)`을 호출한다. +6. stop 시점에는 input 구독 해제, runner 구독 해제, runner stop, disconnect 훅 정리를 순서대로 수행한다. +7. process exit/raw mode/stdout clear 같은 전역 부작용은 이 레이어 밖으로 밀어낸다. + +## 설계 메모 + +- 이 레이어는 **CLI orchestration**만 담당한다. + - transport를 직접 만들지 않는다. + - room code 입력 UX도 아직 넣지 않는다. + - 실제 `process.stdin.setRawMode(...)`, `readline.createInterface(...)`, `process.stdout.write(...)`는 이후 adapter issue에서 연결한다. +- 기본 입력 정규화는 `trim()`으로 두되, 필요하면 옵션으로 교체할 수 있게 한다. +- start 시퀀스는 다음 순서를 따른다. + 1. optional `connect()` + 2. `runner.start()` + 3. runner state 구독 및 초기 repaint + 4. input source 구독 +- stop 시퀀스는 다음 순서를 따른다. + 1. input source 구독 해제 + 2. runner state 구독 해제 + 3. `runner.stop()` + 4. optional `disconnect()` +- start/stop은 deterministic test로 검증 가능해야 한다. + +## 기대 public contract + +- `createPvpSessionTerminalCli(...)` +- `cli.start()` / `cli.stop()` +- `cli.getState()` +- `PvpSessionTerminalCliInputSource` +- `PvpSessionTerminalCliScreenOutput` +- `PvpSessionTerminalCliBootstrapHooks` + +## 완료 조건 + +- start 시 connect bootstrap → runner start → 초기 repaint가 안정적으로 실행된다. +- input source가 전달한 token이 runner submit contract로 이어진다. +- runner state update가 screen repaint로 반영된다. +- stop 이후에는 추가 입력/runner update가 더 이상 전달되지 않는다. +- 이후 issue는 이 contract 위에 실제 raw stdin/readline adapter와 room join/bootstrap UX만 얹으면 된다. diff --git a/docs/pvp/implementation/issues/ISSUE-22-room-session-bootstrap.md b/docs/pvp/implementation/issues/ISSUE-22-room-session-bootstrap.md new file mode 100644 index 00000000..7d91de69 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-22-room-session-bootstrap.md @@ -0,0 +1,67 @@ +# ISSUE-22 · room create/join + session bootstrap + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-21 · session terminal CLI bootstrap](./ISSUE-21-session-terminal-cli.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [친구전 룸 / 매치 성립 상세 계약](../../server/contracts/room-and-match.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +ISSUE-21에서 고정한 testable CLI bootstrap 경계 위에, 실제 온라인 PvP 진입에 필요한 **room create/join + session bootstrap domain** 을 추가한다. + +이 단계에서는 raw stdin/readline, stdout repaint, process-global side effect를 직접 다루지 않는다. 대신 HTTP room API와 WebSocket session bootstrap을 작은 도메인 계층으로 묶어서, 상위 CLI/ battle-tui가 `create room` / `join room` / `connect live session` 흐름을 안정적으로 호출할 수 있게 하는 것이 목적이다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/room-http-client.ts` +- `src/pvp/session-bootstrap.ts` +- `src/pvp/index.ts` + +### 테스트 + +- `test/pvp-room-http-client.test.ts` +- `test/pvp-session-bootstrap.test.ts` + +## 핵심 책임 + +1. room create / join / get 호출을 담당하는 작은 HTTP client layer를 제공한다. +2. `Authorization` / JSON body / error envelope 처리를 client domain 안에서 일관되게 감싼다. +3. room 응답(`RoomView`)에서 live session 접속에 필요한 `roomId`를 안전하게 추출한다. +4. 상위 consumer가 room bootstrap 결과를 그대로 `PvpSessionClient`로 연결할 수 있게 한다. +5. bootstrap 단계에서 WebSocket URL 조립 로직을 다시 발명하지 않고, 기존 `createPvpSessionClient` / `createPvpWebSocketUrl` contract를 재사용한다. +6. 실제 CLI/battle-tui는 이후 이 contract 위에 room code 입력, stdin 루프, repaint policy만 얹으면 되게 만든다. + +## 설계 메모 + +- room HTTP layer는 transport 세부 구현보다 **입력/출력 contract 고정**이 우선이다. +- create/join bootstrap은 `RoomView`를 받은 뒤 session client를 만드는 단계까지 포함하지만, 자동 connect 여부는 옵션으로 남긴다. +- client domain은 서버 route handler 구현을 직접 호출하지 않고, fetch-like interface를 통해 네트워크 경계를 유지한다. +- non-2xx 응답과 malformed payload는 typed error로 올려서 상위 CLI가 메시지를 정리할 수 있게 한다. +- generation / ruleset / roomCode 검증의 authoritative source는 여전히 서버다. client는 server error envelope만 surface 한다. +- 이 단계에서는 room polling / reconnect recovery / room waiting screen UX를 완성하려 하지 않는다. 그건 이후 interactive adapter/consumer 단계에서 붙인다. + +## 기대 public contract + +- `createPvpRoomHttpClient(...)` +- `PvpRoomHttpClient` +- `PvpRoomHttpClientError` (`network_error` / `http_error` / `invalid_response`) +- `createPvpSessionBootstrap(...)` +- `bootstrap.createRoomSession(...)` +- `bootstrap.joinRoomSession(...)` +- `bootstrap.resumeRoomSession(...)` +- `bootstrap.createSessionFromRoomView(...)` + +bootstrap 결과는 최소 다음을 포함한다. + +- `roomView` +- `roomId` +- `sessionClient` + +## 완료 조건 + +- create room 요청이 HTTP contract를 통해 `RoomView`로 돌아온다. +- join room 요청이 room code + room id를 함께 사용해 성공/실패를 구분한다. +- 오류 응답은 typed error(`PvpRoomHttpClientError`)로 surface 된다. +- bootstrap helper가 room 응답 기준으로 `PvpSessionClient`를 만들 수 있고, 필요 시 기존 `RoomView`에서 바로 세션을 만들 수 있다. +- 이후 issue는 이 contract 위에 실제 room-code 입력 UX와 raw stdin/stdout adapter만 추가하면 된다. diff --git a/docs/pvp/implementation/issues/ISSUE-23-session-terminal-stdio-adapters.md b/docs/pvp/implementation/issues/ISSUE-23-session-terminal-stdio-adapters.md new file mode 100644 index 00000000..ca413129 --- /dev/null +++ b/docs/pvp/implementation/issues/ISSUE-23-session-terminal-stdio-adapters.md @@ -0,0 +1,46 @@ +# ISSUE-23 · session terminal stdio adapters + +상위 문서: [PvP 구현 이슈 분해](./README.md) +선행 이슈: [ISSUE-22 · room create/join + session bootstrap](./ISSUE-22-room-session-bootstrap.md) +관련 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [실시간 배틀 세션 상세 계약](../../server/contracts/realtime-battle-session.md) + +## 목표 + +ISSUE-22에서 고정한 room/session bootstrap contract와 ISSUE-21의 testable CLI orchestration layer 위에, 실제 터미널에서 돌아가는 **stdin/stdout adapter layer** 를 올린다. + +이번 단계의 목적은 사용자가 tokenmon/ battle-tui에서 곧바로 live PvP에 들어갈 수 있도록, raw stdin/readline/stdout repaint를 안전한 adapter 뒤로 밀어 넣는 것이다. + +## 구현 범위 + +### 신규/확장 모듈 + +- `src/pvp/session-terminal-stdio.ts` 또는 동등한 adapter 모듈 +- `src/cli/` live PvP entrypoint 연동부 +- 필요 시 `src/pvp/index.ts` + +### 테스트 + +- stdin token source 단위 테스트 +- stdout repaint/cleanup 단위 테스트 +- 상위 CLI entrypoint smoke test + +## 핵심 책임 + +1. `PvpSessionTerminalCliInputSource`를 실제 stdin/readline 구현과 연결한다. +2. `PvpSessionTerminalCliScreenOutput`을 실제 stdout repaint/clear 정책과 연결한다. +3. start/stop 시 raw mode 진입/해제를 deterministic cleanup 순서로 보장한다. +4. Ctrl+C / EOF / 종료 훅과 live session disconnect를 정리된 순서로 연결한다. +5. room create/join bootstrap 결과를 받아 실제 터미널 PvP 루프를 시작하는 entrypoint를 만든다. + +## 설계 메모 + +- process-global side effect는 adapter 안으로 가두고, runner/bootstrap domain은 순수 contract로 유지한다. +- repaint 정책은 최소 flicker로 시작하되, 이후 ANSI/battle-tui 최적화 가능성을 열어 둔다. +- stdin token normalization은 ISSUE-21 contract를 따르며, 실제 키 매핑은 adapter에서만 추가한다. +- stop 시 cleanup 순서는 raw mode 해제 누락이 없도록 테스트로 고정한다. + +## 완료 조건 + +- 실제 stdin/stdout 환경에서 live PvP CLI를 시작/정지할 수 있다. +- room create/join + connect + repaint가 end-to-end로 이어진다. +- 종료 후 raw mode/readline/resource leak가 남지 않는다. diff --git a/docs/pvp/implementation/issues/README.md b/docs/pvp/implementation/issues/README.md new file mode 100644 index 00000000..0fedf018 --- /dev/null +++ b/docs/pvp/implementation/issues/README.md @@ -0,0 +1,87 @@ +# PvP 구현 이슈 분해 + +상위 문서: [PvP 구현 계획 문서](../README.md) +기반 문서: [PvP 작업 분해 / TODO](../todo-breakdown.md), [PvP 초기 구현 PRD](../prd.md), [서버 패키지 / 모듈 구조 제안](../server-package-layout.md) + +## 목적 + +이 디렉터리는 PvP MVP를 **실제 구현에 바로 착수할 수 있는 이슈 단위**로 쪼갠다. +각 이슈는 다음 기준을 만족하도록 설계한다. + +- 한 번의 구현 스프린트에서 끝낼 수 있을 것 +- 테스트 경계가 분명할 것 +- 다음 단계의 의존성이 명확할 것 +- `src/core` 재사용과 `src/server` 신설 경계를 흐리지 않을 것 + +## 권장 실행 순서 + +1. [ISSUE-01 · ruleset / 정책 기반 계층](./ISSUE-01-ruleset-foundation.md) +2. [ISSUE-02 · 온라인 파티 검증 도메인](./ISSUE-02-party-validation-domain.md) +3. [ISSUE-03 · 온라인 파티 등록 API / 저장](./ISSUE-03-party-registration-surface.md) +4. [ISSUE-04 · 친구전 룸 도메인 / 저장](./ISSUE-04-room-domain-and-persistence.md) +5. [ISSUE-05 · 룸 API / 매치 성립 흐름](./ISSUE-05-room-http-and-readiness.md) +6. [ISSUE-06 · 서버 권한 배틀 세션 코어](./ISSUE-06-battle-session-domain.md) +7. [ISSUE-07 · 실시간 명령 게이트웨이](./ISSUE-07-realtime-command-gateway.md) +8. [ISSUE-08 · 재접속 / 운영 안정화](./ISSUE-08-reconnect-and-ops.md) +9. [ISSUE-09 · 클라이언트 배틀 세션 스토어](./ISSUE-09-client-session-store.md) +10. [ISSUE-10 · 클라이언트 프로토콜 어댑터](./ISSUE-10-client-protocol-adapter.md) +11. [ISSUE-11 · WebSocket 클라이언트 커넥터](./ISSUE-11-websocket-client-connector.md) +12. [ISSUE-12 · 클라이언트 재접속 / backoff 컨트롤러](./ISSUE-12-client-reconnect-controller.md) +13. [ISSUE-13 · 상위 PvP session client orchestration 레이어](./ISSUE-13-session-client-orchestrator.md) +14. [ISSUE-14 · PvP action request rendering / input UX adapter](./ISSUE-14-action-request-view.md) +15. [ISSUE-15 · PvP turn resolved rendering / 결과 로그 UX adapter](./ISSUE-15-turn-result-view.md) +16. [ISSUE-16 · PvP submitted-command / acceptance-status 읽기 모델](./ISSUE-16-command-status-view.md) +17. [ISSUE-17 · PvP session-level screen 읽기 모델](./ISSUE-17-session-screen-view.md) +18. [ISSUE-18 · deterministic PvP session terminal renderer](./ISSUE-18-session-screen-renderer.md) +19. [ISSUE-19 · PvP session terminal controller / input token bridge](./ISSUE-19-session-terminal-controller.md) +20. [ISSUE-20 · live PvP session terminal runner](./ISSUE-20-session-terminal-runner.md) +21. [ISSUE-21 · session terminal CLI bootstrap](./ISSUE-21-session-terminal-cli.md) +22. [ISSUE-22 · room create/join + session bootstrap](./ISSUE-22-room-session-bootstrap.md) +23. [ISSUE-23 · session terminal stdio adapters](./ISSUE-23-session-terminal-stdio-adapters.md) + +## 왜 이 순서인가 + +PvP에서 가장 먼저 고정되어야 하는 것은 **온라인에서 무엇이 합법 파티인지**다. +ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정되지 않으면 파티 등록, 룸 매칭, 배틀 로그 해석이 모두 흔들린다. + +따라서 초기 착수는 반드시 **정책 계층 → 검증 계층 → 등록 surface** 순으로 간다. + +서버 권한 배틀과 재접속 정책이 먼저 완성된 뒤에야, Claude Code / TUI 클라이언트가 신뢰할 수 있는 읽기 모델을 얹을 수 있다. +그래서 클라이언트 측 구현은 **서버 authoritative contract 고정 이후**에 별도 이슈로 분리한다. + +## 첫 실행 대상 + +현재 첫 구현 대상은 [ISSUE-01 · ruleset / 정책 기반 계층](./ISSUE-01-ruleset-foundation.md)이다. + +이 이슈가 끝나면 다음 이슈들이 바로 그 위에 올라탈 수 있다. + +- ISSUE-02는 ISSUE-01의 ruleset / restricted / 레벨 정책을 그대로 사용한다. +- ISSUE-03은 ISSUE-02의 검증 결과와 ISSUE-01의 ruleset 조회를 HTTP surface로 노출한다. + +## 구현 체크포인트 매핑 + +| 체크포인트 | 필요한 이슈 | +| --- | --- | +| A. 등록만 되는 상태 | ISSUE-01, ISSUE-02, ISSUE-03 | +| B. 룸까지 되는 상태 | ISSUE-04, ISSUE-05 | +| C. 배틀 되는 상태 | ISSUE-06, ISSUE-07 | +| D. 실제 사용 가능한 상태 | ISSUE-08 | +| E. 클라이언트 통합 시작 상태 | ISSUE-09, ISSUE-10, ISSUE-11 | +| F. 실제 접속 안정화 시작 상태 | ISSUE-12 | +| G. 상위 클라이언트 진입점 정리 상태 | ISSUE-13 | +| H. 턴 결과 렌더링 진입점 정리 상태 | ISSUE-14, ISSUE-15 | +| I. 제출 상태/접수 상태 읽기 모델 정리 상태 | ISSUE-16 | +| J. 세션 단위 상위 화면 view model 정리 상태 | ISSUE-17 | +| K. 상위 consumer용 deterministic terminal renderer 정리 상태 | ISSUE-18 | +| L. battle-tui/CLI 연결 전 terminal controller + deterministic token submit contract 정리 상태 | ISSUE-19 | +| M. live session 구독 + submit orchestration runner 정리 상태 | ISSUE-20 | +| N. 테스트 가능한 live CLI bootstrap/start-stop 경계 정리 상태 | ISSUE-21 | +| O. room create/join + websocket session bootstrap 도메인 정리 상태 | ISSUE-22 | +| P. 실제 stdin/stdout adapter + live entrypoint 정리 상태 | ISSUE-23 | + +## 공통 실행 규칙 + +- 각 이슈는 가능하면 **테스트 우선(TDD)** 으로 진행한다. +- `src/core`를 수정해야 한다면, 서버 계층이 요구하는 최소한의 재사용 지점만 만든다. +- 서버가 최종 권위자인 값은 클라이언트 입력에서 받지 않는다. +- 이후 이슈가 필요로 하는 식별자(`rulesetKey`, `snapshotId`, `roomId`, `battleId`)는 초기에 안정 키로 설계한다. diff --git a/docs/pvp/implementation/prd.md b/docs/pvp/implementation/prd.md new file mode 100644 index 00000000..a774c2fc --- /dev/null +++ b/docs/pvp/implementation/prd.md @@ -0,0 +1,164 @@ +# PvP 초기 구현 PRD + +상위 문서: [PvP 구현 계획 문서](./README.md) +기반 설계 문서: [PvP 문서 인덱스](../README.md), [배틀 포맷](../game-design/battle-format.md), [서버 아키텍처](../server/architecture.md), [치트 대응 정책](../security/anti-cheat.md) + +## 1. 문제 정의 + +Tokénmon은 현재 로컬/스토리 중심 구조이기 때문에, 플레이어가 서로 직접 붙는 온라인 친선 PvP 경험이 없다. +하지만 사용자는 다음을 동시에 원한다. + +1. 로컬에서 키운 파티를 온라인에 가져오고 싶다. +2. 인게임에서 NPC 트레이너를 조우한 듯한 감성으로 싸우고 싶다. +3. 전투 결과 위조와 성장 상태 위조는 최대한 막고 싶다. + +따라서 초기 PvP는 **서버 권한 전투 + 세대별 등록 파티 + 숨은 정보 기반 친선전**을 최소 제품으로 삼는다. + +## 2. 제품 목표 + +### 핵심 목표 + +- 플레이어가 세대별 온라인 파티를 등록할 수 있다. +- 친구 코드/룸 코드 기반으로 친선 PvP 룸을 열고 참가할 수 있다. +- 배틀은 서버가 계산하고, 클라이언트는 명령만 보낸다. +- 상대 엔트리를 모르는 6v6 싱글 배틀을 실시간으로 진행할 수 있다. +- 최소한 전투 결과 위조와 치트 오염 저장의 온라인 사용을 막는다. + +### 성공 기준 + +초기 릴리즈에서 다음이 되면 성공으로 본다. + +- 플레이어 A/B가 같은 세대 ruleset으로 룸에 들어간다. +- 서버가 양측 활성 등록 파티를 검증하고 배틀을 시작한다. +- 각 턴에서 move / switch / replacement / forfeit 명령이 정상 처리된다. +- 배틀 종료 후 서버에 승패와 이벤트 로그가 남는다. +- 치트 오염 상태 저장은 온라인 진입이 거부된다. + +## 3. 비목표 + +초기 버전에서 아래는 하지 않는다. + +- 래더 / MMR / 매치메이킹 +- 관전 / 리플레이 완성형 기능 +- 공개 팀 시트 +- 밴픽 / 토너먼트 브래킷 +- 더블 배틀 +- 서버 주도 성장 시스템 전체 이관 + +## 4. 대상 사용자 + +### 1차 사용자 +- Tokénmon을 이미 플레이하고 있는 기존 유저 +- 로컬에서 키운 파티를 친구와 붙여 보고 싶은 유저 +- 경쟁 e스포츠보다 인게임 감성을 더 중요하게 보는 유저 + +### 2차 사용자 +- 이후 시즌/래더/관전 기능이 들어오면 경쟁 플레이를 원하는 유저 + +## 5. 사용자 경험 원칙 + +- UI/흐름은 “대회 참가”보다 “게임 안에서 바로 트레이너 배틀 시작”에 가깝다. +- 파티 프리뷰 없이 바로 시작한다. +- 1번 슬롯이 선발이다. +- 기절하면 다음 포켓몬을 직접 고른다. +- 상대의 백라인은 끝까지 비공개다. +- 실제 레벨은 보이되, PvP 계산용 레벨은 압축한다. + +## 6. 기능 요구사항 + +### F1. 세대별 ruleset 조회 +- 클라이언트가 현재 세대의 온라인 룰을 조회할 수 있어야 한다. +- ruleset에는 party size, duplicate clause, legendary/mythical limit, restricted limit, effective level cap 등이 포함된다. + +### F2. 세대별 온라인 파티 등록 +- 플레이어는 특정 세대에 대해 온라인 파티를 등록/갱신할 수 있어야 한다. +- 서버는 파티의 유효성을 검증해야 한다. +- 초기 구조는 세대당 활성 파티 1개다. + +### F3. 룸 생성/참가 +- 플레이어 A가 룸을 생성한다. +- 플레이어 B가 룸 코드로 참가한다. +- 룸 생성 시 generation / ruleset이 확정된다. + +### F4. 서버 권한 배틀 진행 +- 클라이언트는 명령만 제출한다. +- 서버가 전 턴 결과를 계산한다. +- 서버는 플레이어별 가시 정보만 내려준다. + +### F5. 치트 오염 상태 차단 +- 치트 사용 이력이 있는 저장은 온라인 등록 또는 온라인 진입 시 거부된다. +- 최소 기준은 현재 코드베이스의 치트 플래그/로그를 반영하는 것이다. + +### F6. 배틀 로그 저장 +- 서버는 명령 기록과 결과 이벤트 로그를 저장해야 한다. +- 최소한 재접속 복구와 사후 디버깅이 가능해야 한다. + +## 7. 규칙 요구사항 + +- 싱글 배틀 +- 6마리 등록 / 6마리 전원 사용 +- 팀 프리뷰 없음 +- 1번 슬롯 자동 선발 +- 중복 종 금지 +- legendary + mythical 총 2 제한 +- restricted 최대 1 제한 +- 실제 레벨 표시 유지 +- 50 이후 계산용 레벨 압축 +- 유효 레벨 최대 60 +- 선택 시간 기본 45초 + +## 8. 기술 요구사항 + +### 서버 측 +- party registration API +- room service +- realtime gateway (WebSocket) +- server-authoritative battle orchestration +- persistence for room/commands/events + +### 클라이언트 측 +- ruleset 조회 +- 온라인 파티 등록 UX +- 룸 생성/참가 UX +- 실시간 배틀 입력 UX +- 이벤트 렌더링 UX + +## 9. 리스크 + +### 리스크 A. 로컬 성장 데이터 신뢰성 부족 +로컬 기반 성장 구조 때문에 완전한 무결성 보장은 어렵다. + +대응: +- 서버 등록 스냅샷 사용 +- 치트 오염 저장 차단 +- 배틀 중 실시간 로컬 상태 참조 금지 + +### 리스크 B. 숨은 정보 처리 실수 +서버가 잘못 구현되면 상대 백라인이 누출될 수 있다. + +대응: +- 플레이어별 view projection 구조 강제 +- event/schema 레벨에서 public/private payload 분리 + +### 리스크 C. 재접속/타임아웃 처리 복잡도 +실시간이므로 네트워크 이슈가 필연적이다. + +대응: +- 초기에는 친구전 중심으로 제한 +- room snapshot + battle event log 기반 복구 +- 타임아웃 정책은 단순하게 시작 + +## 10. 수용 기준 + +다음 시나리오가 전부 통과하면 MVP 수용 가능으로 본다. + +1. gen4 파티 등록 성공 +2. 제한 위반 파티 등록 실패 +3. 치트 오염 저장 온라인 등록 실패 +4. 룸 생성 및 코드 참가 성공 +5. 배틀 시작 후 양측 1번 슬롯 선발 확인 +6. move/move 턴 처리 성공 +7. switch/move 턴 처리 성공 +8. faint 후 replacement 요청 성공 +9. 배틀 종료 및 승패 기록 저장 성공 +10. 재접속 시 현재 공개 상태 복구 성공 diff --git a/docs/pvp/implementation/server-package-layout.md b/docs/pvp/implementation/server-package-layout.md new file mode 100644 index 00000000..8effdfe9 --- /dev/null +++ b/docs/pvp/implementation/server-package-layout.md @@ -0,0 +1,510 @@ +# PvP 서버 패키지 / 모듈 구조 제안 + +상위 문서: [PvP 구현 계획 문서](./README.md) +관련 문서: [서버 아키텍처](../server/architecture.md), [서버 DB 스키마 초안](../server/storage/database-schema.md), [HTTP / WebSocket API 계약 초안](../server/api-contract.md), [PvP 작업 분해 / TODO](./todo-breakdown.md) + +## 목적 + +이 문서는 PvP 서버를 실제로 구현할 때 **repo 안에 어떤 디렉터리와 모듈 경계를 두는 게 좋은지**를 정리한다. +즉, 앞선 문서들이 “무슨 기능이 필요한가”를 정의했다면, 이 문서는 “그 기능을 코드베이스 어디에 어떻게 놓을 것인가”를 정리하는 문서다. + +초기 목표는 다음 세 가지다. + +1. 현재 `src/core` 전투 로직을 가능한 한 재사용한다. +2. 온라인 PvP 전용 책임을 `src/server`로 분리한다. +3. HTTP / WebSocket / persistence / battle orchestration 경계를 명확히 만든다. + +--- + +## 현재 repo 기준 전제 + +현재 repo의 주요 구조는 대략 이렇다. + +- `src/core/`: 게임 규칙, 포켓몬 데이터, 전투/성장 로직 +- `src/cli/`: CLI 진입점 +- `src/battle-tui/`: 로컬 전투 렌더링/UI +- `src/hooks/`, `src/setup/`, `src/audio/`, `src/sprites/`: 부가 기능 + +즉, **게임 규칙 엔진은 이미 `src/core`에 있고**, 아직 온라인 서버 전용 계층은 없다. +그래서 초기 PvP 구현은 아래 원칙으로 가는 것이 좋다. + +- **게임 규칙은 `src/core` 중심 재사용** +- **온라인 상태 관리와 입출력은 `src/server`로 신설** +- `src/cli`에는 서버 실행/관리용 진입점만 얇게 둠 + +--- + +## 추천 최상위 구조 + +```text +src/ + core/ + cli/ + battle-tui/ + server/ + index.ts + config/ + auth/ + rules/ + parties/ + rooms/ + battle/ + ws/ + http/ + persistence/ + projection/ + anti-cheat/ + shared/ +``` + +### 왜 `src/server`를 따로 두는가 + +이유는 단순하다. + +- `src/core`는 **오프라인/로컬 전투에서도 재사용 가능한 순수 규칙 계층**으로 남겨야 하고 +- `src/server`는 **온라인 PvP 전용 orchestration 계층**이어야 하기 때문이다. + +이 분리가 있어야 나중에 다음이 쉬워진다. + +- 로컬 battle과 온라인 battle의 경계 유지 +- 서버 테스트에서 I/O 계층 mocking +- 향후 spectator / ladder / replay 확장 + +--- + +## 모듈별 책임 + +## 1. `src/server/index.ts` + +서버 부트스트랩 진입점. + +책임: + +- config 로딩 +- HTTP 서버 생성 +- WebSocket gateway 연결 +- persistence wiring +- route 등록 +- graceful shutdown + +초기에는 여기서 app assembly를 하고, 실제 로직은 하위 디렉터리로 내려보낸다. + +--- + +## 2. `src/server/config/` + +서버 환경설정 로더. + +예상 파일: + +- `env.ts`: 포트, DB URL, WS 설정, timeout 값 +- `pvp-config.ts`: PvP 전용 runtime config + +책임: + +- 환경변수 파싱 +- 기본값 정의 +- 런타임 설정 검증 + +--- + +## 3. `src/server/auth/` + +온라인 PvP용 인증 컨텍스트 변환 계층. + +예상 파일: + +- `player-identity.ts` +- `auth-middleware.ts` +- `token-verifier.ts` 또는 placeholder + +책임: + +- 외부 인증 토큰에서 `player_id` 추출 +- HTTP / WS 요청에 player context 부착 +- 익명/잘못된 접근 차단 + +초기 친선전이라도 `player_id`를 안정적으로 만드는 계층은 미리 분리하는 편이 좋다. + +--- + +## 4. `src/server/rules/` + +세대별 ruleset / restricted 목록 / 레벨 압축 정책 담당. + +예상 파일: + +- `ruleset-repository.ts` +- `ruleset-service.ts` +- `restricted-species.ts` +- `level-compression.ts` +- `ruleset-types.ts` + +책임: + +- generation별 active ruleset 조회 +- restricted 목록 조회 +- 레벨 압축 계산 +- battle start 시 사용할 ruleset snapshot 생성 + +이 계층은 `generation_rulesets`, `restricted_species` 테이블과 직접 대응한다. + +--- + +## 5. `src/server/parties/` + +온라인 파티 등록/조회/검증 담당. + +예상 파일: + +- `party-registration-service.ts` +- `party-validator.ts` +- `party-snapshot-repository.ts` +- `growth-proof.ts` +- `party-types.ts` + +책임: + +- 로컬 파티 입력을 온라인 snapshot으로 정규화 +- duplicate species / legendary / mythical / restricted 제한 검증 +- `level_actual` -> `level_effective` 계산 +- `source_state_hash`, `source_config_hash`, `growth_proof_json` 생성/저장 +- generation별 active snapshot 교체 + +이 계층은 **치트 방어 첫 관문**이다. + +--- + +## 6. `src/server/rooms/` + +친구전 룸 생성/참가/준비 상태 관리. + +예상 파일: + +- `room-service.ts` +- `room-repository.ts` +- `room-code.ts` +- `room-validator.ts` +- `room-types.ts` + +책임: + +- 룸 생성 +- room code 발급 +- generation/ruleset mismatch 차단 +- 양 플레이어 입장 상태 추적 +- battle start 조건 충족 시 방을 in-progress로 전이 + +이 계층은 `battle_rooms`, `battle_room_players`와 대응한다. + +--- + +## 7. `src/server/battle/` + +서버 권한 배틀 orchestration의 핵심. + +예상 파일: + +- `battle-session-service.ts` +- `battle-turn-service.ts` +- `battle-command-service.ts` +- `battle-engine-adapter.ts` +- `battle-event-log.ts` +- `battle-types.ts` +- `timeout-policy.ts` + +책임: + +- 현재 룸의 battle lifecycle 관리 +- `battle_turns` 생성 / 종료 +- 명령 수집 / 중복 방지 / timeout 처리 +- 기존 `src/core/battle.ts`, `src/core/turn-battle.ts`와 연결 +- 계산 결과를 `battle_events`로 append +- 종료/승패 처리 + +### 가장 중요한 경계 + +여기서 중요한 건 **배틀 계산 엔진과 네트워크 입출력을 직접 섞지 않는 것**이다. + +- `battle-engine-adapter.ts`는 `src/core` 호출 담당 +- `battle-session-service.ts`는 turn lifecycle 담당 +- `battle-event-log.ts`는 persistence 및 sequence 보장 담당 + +이렇게 나누면 전투 규칙 변경이 있어도 WS 코드를 건드릴 일이 줄어든다. + +--- + +## 8. `src/server/ws/` + +실시간 WebSocket gateway. + +예상 파일: + +- `pvp-ws-server.ts` +- `connection-registry.ts` +- `message-router.ts` +- `heartbeat.ts` +- `session-resume.ts` + +책임: + +- room 단위 연결 관리 +- 클라이언트 메시지 파싱 +- `battle.command`를 battle 계층으로 라우팅 +- heartbeat / disconnect / reconnect 처리 +- 특정 player에게만 private payload 전달 + +여기는 **transport layer**로 유지하고, 게임 판단은 battle 계층에 넘겨야 한다. + +--- + +## 9. `src/server/http/` + +REST endpoint 계층. + +예상 파일: + +- `routes.ts` +- `ruleset-routes.ts` +- `party-routes.ts` +- `room-routes.ts` +- `serializers.ts` + +책임: + +- ruleset 조회 +- active online party 조회/등록 +- 룸 생성/참가/조회 +- DTO validation +- HTTP 에러 응답 표준화 + +--- + +## 10. `src/server/persistence/` + +DB 접근 계층. + +예상 파일: + +- `db.ts` +- `transaction.ts` +- `repositories/` + - `ruleset-repository.ts` + - `party-repository.ts` + - `room-repository.ts` + - `battle-turn-repository.ts` + - `battle-command-repository.ts` + - `battle-event-repository.ts` + +책임: + +- SQL/ORM 호출 집중 +- 트랜잭션 경계 제공 +- repository 인터페이스 제공 + +중요한 점은 business rule을 repository 안에 과하게 넣지 않는 것이다. +검증/정책은 service 계층, 저장은 repository 계층으로 나눈다. + +--- + +## 11. `src/server/projection/` + +플레이어별 시야 투영 계층. + +예상 파일: + +- `room-snapshot-projection.ts` +- `battle-event-projection.ts` +- `visibility-rules.ts` + +책임: + +- 같은 room state를 p1/p2에게 다르게 직렬화 +- 상대 백라인 비공개 처리 +- public/private payload 구성 + +이 계층을 별도로 두는 이유는 “무엇이 진실인가”와 “무엇을 누구에게 보여줄 것인가”를 분리하기 위해서다. + +--- + +## 12. `src/server/anti-cheat/` + +친선전 v1 범위의 치트 방어 계층. + +예상 파일: + +- `registration-integrity.ts` +- `growth-sanity-check.ts` +- `battle-input-validation.ts` +- `audit-log.ts` + +책임: + +- 파티 등록 입력 검증 +- 비정상 성장 상태 탐지 +- 잘못된 command payload 차단 +- 감사 로그 보조 + +초기에는 여기서 모든 치트를 완벽히 막기보다, **온라인 등록과 배틀 입력에서 최소한의 신뢰 경계**를 세우는 역할이 중요하다. + +--- + +## 13. `src/server/shared/` + +서버 전용 공통 타입 / 에러 / 유틸. + +예상 파일: + +- `errors.ts` +- `result.ts` +- `ids.ts` +- `clock.ts` +- `logger.ts` + +책임: + +- 공통 에러 타입 +- ID 생성 보조 +- 시간/로그 추상화 +- 테스트 가능한 공통 유틸 제공 + +--- + +## `src/core`와의 경계 + +초기 PvP 구현에서 가장 중요한 구조적 원칙 중 하나는 다음이다. + +> 온라인 PvP를 만든다고 해서 배틀 규칙 엔진까지 `src/server`로 복제하지 않는다. + +즉: + +- `src/core/battle.ts` +- `src/core/turn-battle.ts` +- `src/core/moves.ts` +- `src/core/stats.ts` +- `src/core/status-effects.ts` +- `src/core/pokemon-data.ts` + +같은 파일들은 **규칙 엔진 / 도메인 데이터 계층**으로 계속 남기고, +`src/server/battle/battle-engine-adapter.ts`가 이들을 감싸서 온라인 배틀에서 사용하도록 만드는 것이 좋다. + +### 추천 어댑터 책임 + +`battle-engine-adapter.ts`는 다음만 담당한다. + +- room state -> core battle input 변환 +- core battle result -> server event list 변환 +- server RNG seed 주입 +- deterministic replay 가능 형태 유지 + +즉, 서버 battle 계층이 core 엔진을 호출하되, core 엔진이 네트워크나 DB를 알 필요는 없게 만든다. + +--- + +## 추천 CLI 진입점 + +`src/cli/`에는 온라인 PvP용 관리/개발 진입점을 얇게 두는 것을 권장한다. + +예상 파일: + +- `src/cli/pvp-server.ts`: 로컬 개발용 서버 실행 +- `src/cli/pvp-rules.ts`: ruleset 확인/시드용 보조 명령 +- `src/cli/pvp-room-debug.ts`: room 상태 조회/디버그 + +중요한 점은 CLI가 business logic을 직접 갖지 않고, `src/server`를 호출만 하게 하는 것이다. + +--- + +## 테스트 구조 제안 + +```text +test/ + pvp/ + rules/ + parties/ + rooms/ + battle/ + projection/ + anti-cheat/ +``` + +### 테스트 레벨 + +1. **unit** + - 레벨 압축 + - duplicate species 검사 + - restricted limit 검사 + - projection masking + +2. **integration** + - party registration transaction + - room create/join + - battle turn resolve + - reconnect snapshot rebuild + +3. **contract** + - HTTP response shape + - WebSocket message shape + +--- + +## 추천 구현 순서와 패키지 생성 순서 + +### Step 1 +- `src/server/config` +- `src/server/shared` +- `src/server/rules` + +### Step 2 +- `src/server/persistence` +- `src/server/parties` + +### Step 3 +- `src/server/rooms` +- `src/server/http` + +### Step 4 +- `src/server/battle` +- `src/server/projection` +- `src/server/ws` + +### Step 5 +- `src/cli/pvp-server.ts` +- `test/pvp/*` + +이 순서가 좋은 이유는, **정책 -> 저장 -> 룸 -> 배틀 -> 실시간 transport** 순으로 의존성이 자연스럽기 때문이다. + +--- + +## 초기 v1에서 일부러 미루는 것 + +초기 범위에서는 아래는 별도 패키지로 빼지 않아도 된다. + +- ladder matchmaking +- spectator delivery +- replay export service +- seasonal ruleset rotation worker +- analytics/event warehouse sink + +이런 것들은 친선 PvP v1 범위를 넘는다. + +--- + +## 최종 제안 + +초기 PvP 구현은 다음 식으로 이해하면 된다. + +- `src/core`: 배틀 규칙과 게임 도메인 +- `src/server`: 온라인 PvP orchestration +- `src/cli`: 개발/실행 진입점 +- `test/pvp`: 서버 검증 + +이 구조로 가면, 현재 Tokénmon의 로컬 게임 감성을 유지하면서도 **서버 권한 PvP를 무리 없이 얹을 수 있다.** + +--- + +## 다음 문서 + +- [PvP 작업 분해 / TODO](./todo-breakdown.md) +- [서버 DB 스키마 초안](../server/storage/database-schema.md) +- [HTTP / WebSocket API 계약 초안](../server/api-contract.md) diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md new file mode 100644 index 00000000..294b50df --- /dev/null +++ b/docs/pvp/implementation/todo-breakdown.md @@ -0,0 +1,216 @@ +# PvP 작업 분해 / TODO + +상위 문서: [PvP 구현 계획 문서](./README.md) +기반 설계 문서: [PvP 초기 구현 PRD](./prd.md), [구현 로드맵](../roadmap/rollout-plan.md), [서버 DB 스키마 초안](../server/storage/database-schema.md), [서버 패키지 / 모듈 구조 제안](./server-package-layout.md) +실행 이슈 문서: [PvP 구현 이슈 분해](./issues/README.md) + +## 목적 + +이 문서는 초기 PvP를 실제 코드 작업 단위로 쪼갠다. +순서는 **규칙 확정 → 등록 → 룸 → 서버 권한 배틀 → UX → 안정화** 기준으로 잡는다. + +실제 executor 작업은 이 문서를 다시 한 번 정리한 [구현 이슈 문서](./issues/README.md)를 기준으로 나눈다. + +--- + +## Phase 0. 규칙/정책 고정 + +### TODO +- [ ] generation별 ruleset 키 포맷 결정 +- [ ] restricted 시드 리스트를 generation별 정책 데이터로 정리 +- [ ] 레벨 압축 공식을 문서에서 구현 규칙으로 확정 +- [ ] 치트 오염 상태의 판정 기준 확정 + +### 산출물 +- 정책 파일 또는 정책 로더 초안 +- ruleset identifier 규칙 +- restricted seed data + +--- + +## Phase 1. 온라인 파티 등록 + +상세 기준 문서: [온라인 파티 등록 상세 계약](../server/contracts/party-registration.md) + +### 서버 작업 +- [ ] generation ruleset 조회 endpoint 추가 +- [ ] active online party 조회 endpoint 추가 +- [ ] active online party 등록/갱신 endpoint 추가 +- [ ] 파티 유효성 검증 로직 추가 +- [ ] duplicate species / special limit 검증 로직 추가 +- [ ] effective level 계산 로직 추가 +- [ ] 치트 오염 저장 차단 로직 추가 + +### 데이터 작업 +- [ ] `generation_rulesets` 저장 구조 추가 +- [ ] `restricted_species` 저장 구조 추가 +- [ ] `online_party_snapshots` 저장 구조 추가 +- [ ] `online_party_members` 저장 구조 추가 +- [ ] active snapshot 교체 트랜잭션 규칙 추가 +- [ ] `source_state_hash` / `source_config_hash` / `growth_proof_json` 저장 정책 고정 + +### 클라이언트 작업 +- [ ] generation별 온라인 룰 조회 화면/명령 추가 +- [ ] 온라인 파티 등록 진입 UX 추가 +- [ ] 로컬 포켓몬 목록에서 등록 후보 선택 UX 추가 +- [ ] 등록 실패 사유 메시지 정의 + +### 파일/모듈 후보 +- 서버 신규 디렉터리: `src/server/` +- 상세 구조 기준: [서버 패키지 / 모듈 구조 제안](./server-package-layout.md) +- CLI 진입점 후보: `src/cli/pvp-server.ts`, `src/cli/pvp-rules.ts` + +--- + +## Phase 2. 친구전 룸 시스템 + +상세 기준 문서: [친구전 룸 / 매치 성립 상세 계약](../server/contracts/room-and-match.md) + +### 서버 작업 +- [ ] 룸 생성 endpoint 추가 +- [ ] 룸 참가 endpoint 추가 +- [ ] room code 생성기 추가 +- [ ] room state persistence 추가 +- [ ] generation/ruleset mismatch 검증 추가 +- [ ] battle start 시점 `ruleset_snapshot_json` freeze 처리 추가 + +### 데이터 작업 +- [ ] `battle_rooms` 저장 구조 추가 +- [ ] `battle_room_players` 저장 구조 추가 +- [ ] room/player unique 제약 추가 + +### 클라이언트 작업 +- [ ] 룸 생성 UX 추가 +- [ ] 룸 코드 입력 후 참가 UX 추가 +- [ ] 룸 대기 상태 표시 UX 추가 + +### 수용 테스트 +- [ ] 룸 생성 후 상대 참가 전까지 `waiting_for_opponent` 상태 유지 +- [ ] generation mismatch 참가 실패 +- [ ] 두 플레이어 binding 완료 후 `awaiting_presence` 진입 +- [ ] 양 플레이어 실시간 접속 완료 후 `starting` 전이 + +--- + +## Phase 3. 서버 권한 배틀 코어 + +상세 기준 문서: [실시간 배틀 세션 상세 계약](../server/contracts/realtime-battle-session.md) + +### 서버 작업 +- [ ] WebSocket 연결 진입점 추가 +- [ ] room snapshot 송신 구현 +- [ ] `battle.command` 처리기 구현 +- [ ] 턴 수집기(command collector) 구현 +- [ ] 배틀 계산 어댑터 구현 +- [ ] turn resolved event 생성기 구현 +- [ ] faint 후 replacement phase 구현 +- [ ] victory / forfeit 종료 처리 구현 +- [ ] timeout 처리 정책 구현 + +### 데이터 작업 +- [ ] `battle_turns` 저장 구조 추가 +- [ ] `battle_commands` 저장 구조 추가 +- [ ] `battle_events` 저장 구조 추가 +- [ ] public / private payload 분리 저장 규칙 추가 + +### 클라이언트 작업 +- [x] action request 렌더링 +- [x] move/switch/replacement 입력 UX adapter 구현 +- [x] command accepted 상태 반영 +- [x] turn resolved 이벤트 렌더링 +- [x] 클라이언트 세션 스토어(`src/pvp/session-store.ts`) 구현 +- [x] 클라이언트 프로토콜 어댑터(`src/pvp/client-protocol.ts`) 구현 +- [x] WebSocket 클라이언트 커넥터(`src/pvp/websocket-client.ts`) 구현 + +### 주의사항 +- 클라이언트는 결과 계산 금지 +- 상대 백라인 정보 누출 금지 +- public/private payload 분리 강제 + +--- + +## Phase 4. 재접속 / 안정화 + +### 서버 작업 +- [ ] room snapshot 재구성 로직 추가 +- [ ] last visible state 복구 로직 추가 +- [ ] reconnect 시 타이머 재계산 로직 추가 +- [ ] 중복 명령 제출 방지 처리 추가 + +### 클라이언트 작업 +- [ ] 끊김 후 재접속 UX 추가 +- [ ] 이미 제출한 명령 표시 처리 +- [ ] 진행 중 턴 상태 복원 처리 +- [x] session-level PvP screen view model 정리 +- [x] deterministic session terminal renderer 추가 +- [x] WebSocket connector 위에서 reconnect/backoff 정책 정리 +- [x] 상위 PvP session client orchestration 레이어 추가 + +### 실행 이슈 메모 + +- Phase 3 클라이언트 기초는 ISSUE-09 ~ ISSUE-11로 완료 +- Phase 4 재접속 controller는 ISSUE-12로 완료 +- 상위 PvP session client facade는 ISSUE-13으로 완료 +- PvP action request 렌더링 / 입력 UX adapter는 ISSUE-14로 완료 +- turn resolved 결과 로그 / summary adapter는 ISSUE-15로 완료 +- submitted-command / acceptance-status adapter는 ISSUE-16으로 완료 +- session-level screen view model adapter는 ISSUE-17로 완료 +- deterministic session terminal renderer는 ISSUE-18로 완료 +- ISSUE-19는 renderer/action-request/session-client를 묶고, plain-text screen + input token submit/result contract를 고정하는 terminal controller 슬라이스로 진행 +- session-store에 last resolved payload를 보존하면 향후 reconnect 뒤 full log 재생 UX까지 확장 가능 +- ISSUE-17 이후 상위 consumer는 session snapshot 하나로 transport/session/request/command/result를 함께 소비할 수 있으며, ISSUE-18은 이를 plain-text terminal layout으로 고정한다. +- ISSUE-18 이후 다음 슬라이스는 battle-tui/cli에 이 deterministic renderer를 직접 붙이기 전에, input token bridge를 가진 terminal controller layer를 먼저 고정한다. +- ISSUE-19 이후 battle-tui/cli는 controller contract 위에 stdin loop, room join flow, 화면 refresh 정책만 얹으면 된다. +- ISSUE-20은 controller 위에 live session subscribe/start-stop/submit orchestration을 얹는 runner 슬라이스로 진행하며, 이후 실제 CLI는 stdin loop와 bootstrap만 추가로 붙인다. +- ISSUE-21은 runner 위에 testable CLI bootstrap/start-stop/input-output abstraction을 얹는 슬라이스로 진행하며, room create/join UX나 websocket transport 생성은 아직 포함하지 않는다. +- ISSUE-22는 room create/join HTTP contract와 websocket session bootstrap domain을 추가해, 실제 live PvP 진입에 필요한 room/session wiring을 고정한다. +- ISSUE-23은 ISSUE-22 bootstrap 위에 raw stdin/readline/stdout adapter와 실제 CLI entrypoint를 얹는 슬라이스로 진행한다. + +--- + +## Phase 5. 운영/밸런스 후속 + +### 운영 작업 +- [ ] restricted 목록 조정 프로세스 정의 +- [ ] ruleset versioning 전략 확정 +- [ ] 배틀 로그 디버깅 도구 추가 + +### 확장 후보 +- [ ] spectator mode +- [ ] replay export +- [ ] ladder mode +- [ ] multiple online party slots per generation + +--- + +## 추천 구현 순서 + +1. 정책 데이터와 ruleset 저장 구조부터 만든다. +2. 온라인 파티 등록을 먼저 만든다. +3. 룸 시스템을 만든다. +4. 그 위에 서버 권한 배틀을 얹는다. +5. 마지막에 재접속/운영 안정화를 붙인다. + +이 순서를 추천하는 이유는, **온라인에서 무엇이 유효한 파티인지 먼저 고정되지 않으면 그 다음 단계가 전부 흔들리기 때문**이다. + +--- + +## 구현 체크포인트 + +### 체크포인트 A. 등록만 되는 상태 +- ruleset 조회 가능 +- generation별 파티 등록 가능 +- 제한 위반 파티 거부 가능 + +### 체크포인트 B. 룸까지 되는 상태 +- 룸 생성/참가 가능 +- 양측 활성 파티 묶기 가능 + +### 체크포인트 C. 배틀 되는 상태 +- turn loop 작동 +- 종료/승패 저장 가능 + +### 체크포인트 D. 실제 사용 가능한 상태 +- 재접속 복구 가능 +- 기본 오류 메시지 정리 완료 +- 로그 디버깅 가능 diff --git a/docs/pvp/roadmap/README.md b/docs/pvp/roadmap/README.md new file mode 100644 index 00000000..10d77b2e --- /dev/null +++ b/docs/pvp/roadmap/README.md @@ -0,0 +1,15 @@ +# PvP 로드맵 문서 + +상위 문서: [PvP 문서 인덱스](../README.md) + +이 섹션은 Tokénmon 온라인 PvP를 **어떤 순서로 구현할지** 정리한다. + +## 포함 문서 + +1. [구현 로드맵](./rollout-plan.md) + +## 목적 + +- 설계를 바로 구현 가능한 단계로 분해 +- 초기 친선 PvP에서 꼭 필요한 범위 우선 정리 +- 이후 래더/관전/리플레이 확장 여지 확보 diff --git a/docs/pvp/roadmap/rollout-plan.md b/docs/pvp/roadmap/rollout-plan.md new file mode 100644 index 00000000..2109464c --- /dev/null +++ b/docs/pvp/roadmap/rollout-plan.md @@ -0,0 +1,128 @@ +# PvP 구현 로드맵 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [서버 아키텍처](../server/architecture.md), [치트 대응 정책](../security/anti-cheat.md), [API 계약](../server/api-contract.md) + +## 목표 + +이 로드맵은 현재 설계를 실제 개발 단계로 어떻게 나눌지 정리한다. +핵심 원칙은 **작게 시작하고, 서버 권한 구조를 먼저 굳히는 것**이다. + +## 단계 0. 규칙 고정 + +목표: + +- 초기 친선 PvP 규칙 확정 +- 세대별 ruleset / restricted 시드 목록 확정 +- 레벨 압축 정책 확정 + +산출물: + +- ruleset 정의 문서 +- restricted 목록 정책 데이터 초안 + +## 단계 1. 온라인 파티 등록 + +목표: + +- 세대별 온라인 파티 등록/갱신 API 구현 +- 중복 종 / 특수 포켓몬 제한 검증 구현 +- 치트 오염 상태 차단 + +산출물: + +- party registration endpoint +- validation layer +- active party snapshot storage + +## 단계 2. 친구전 룸 시스템 + +목표: + +- 룸 생성/참가 +- room code 기반 초대 +- 세대 / ruleset 고정 + +산출물: + +- room service +- room persistence +- reconnect 가능한 room snapshot 기초 + +## 단계 3. 서버 권한 배틀 코어 + +목표: + +- WebSocket 기반 실시간 명령 처리 +- 서버 턴 계산 +- battle_commands / battle_events 저장 +- 승패 판정 + +산출물: + +- realtime gateway +- battle simulation adapter +- battle event log + +## 단계 4. 클라이언트 연결 UX + +목표: + +- Claude Code 클라이언트에서 룸 접속/배틀 입력 UX 정리 +- 실시간 상태 렌더링 +- 기절 후 교체 플로우 정리 + +산출물: + +- connect flow +- command submission UX +- battle event rendering + +## 단계 5. 운영 안정화 + +목표: + +- 재접속 처리 +- 타임아웃 정책 정교화 +- 로그 기반 디버깅 +- ruleset 버전 관리 체계 확정 + +## 단계 6. 차후 확장 후보 + +- 다중 온라인 파티 슬롯 +- 관전 +- 리플레이 +- 시즌 ruleset +- 래더 +- 더블 배틀 + +## 구현 순서 요약 + +| 순서 | 우선순위 | 이유 | +|---|---|---| +| 1 | 파티 등록 | 온라인 진입 기준을 먼저 세워야 함 | +| 2 | 룸 시스템 | 친구전 시작점 필요 | +| 3 | 서버 권한 배틀 | 핵심 가치 구현 | +| 4 | 클라이언트 UX | 실제 플레이 가능 상태 완성 | +| 5 | 안정화 | 재접속/로그/운영 대응 | + +## 초기 구현 체크리스트 + +- [ ] 세대별 ruleset 저장 구조 결정 +- [ ] restricted 목록 관리 방식 결정 +- [ ] 온라인 파티 스냅샷 데이터 구조 확정 +- [ ] 치트 오염 저장 차단 방식 확정 +- [ ] room code 생성 규칙 결정 +- [ ] WebSocket 이벤트 스키마 확정 +- [ ] battle event 로그 형식 확정 + +## 문서 사용법 + +이 문서는 일정표가 아니라 **구현 분해 기준**이다. +실제 개발에 들어갈 때는 이 로드맵을 기반으로 별도의 구현 계획 문서를 만들고, 각 단계별로 테스트 전략과 파일 변경 범위를 더 구체화하면 된다. + +## 관련 문서 + +- [PvP 문서 인덱스](../README.md) +- [치트 대응 정책](../security/anti-cheat.md) +- [API 계약](../server/api-contract.md) diff --git a/docs/pvp/security/README.md b/docs/pvp/security/README.md new file mode 100644 index 00000000..3e824938 --- /dev/null +++ b/docs/pvp/security/README.md @@ -0,0 +1,15 @@ +# PvP 보안 / 치트 대응 문서 + +상위 문서: [PvP 문서 인덱스](../README.md) + +이 섹션은 Tokénmon 온라인 PvP의 **신뢰성, 부정행위 방지, 운영 기준**을 정의한다. + +## 포함 문서 + +1. [치트 대응 정책](./anti-cheat.md) + +## 핵심 초점 + +- 전투 결과 위조 방지 +- 파티 성장 상태 위조 억제 +- 친선전 수준에서 현실적인 보안 절충 정의 diff --git a/docs/pvp/security/anti-cheat.md b/docs/pvp/security/anti-cheat.md new file mode 100644 index 00000000..e11aaf10 --- /dev/null +++ b/docs/pvp/security/anti-cheat.md @@ -0,0 +1,103 @@ +# 치트 대응 정책 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [서버 아키텍처](../server/architecture.md), [성장 및 파티 등록](../game-design/progression-and-party-registration.md), [API 계약](../server/api-contract.md), [구현 로드맵](../roadmap/rollout-plan.md) + +## 가장 막고 싶은 것 + +초기 PvP에서 최우선으로 막아야 할 것은 다음 두 가지다. + +1. **전투 결과 위조** +2. **자신의 파티 성장 상태 위조** + +이 두 가지를 막지 못하면 온라인 PvP는 금방 신뢰를 잃는다. + +## 핵심 방어 원칙 + +### 1. 전투는 서버 권한 + +가장 중요한 원칙이다. + +- 클라이언트는 결과를 계산하지 않는다. +- 서버만 승패를 판정한다. +- 서버만 배틀 로그를 확정한다. + +이렇게 해야 클라이언트가 “내가 이겼다”고 주장하는 구조를 원천적으로 제거할 수 있다. + +### 2. 온라인은 등록 스냅샷 기반 + +배틀 직전 로컬 메모리를 그대로 읽어 싸우게 하면 조작 여지가 너무 크다. +따라서 서버가 검증해 저장한 **온라인 파티 스냅샷**을 기준으로 싸워야 한다. + +### 3. 치트 오염 상태는 온라인 불가 + +현재 코드베이스에는 치트 관련 진입점이 존재한다. 초기 PvP에서는 최소한 다음 중 하나가 필요하다. + +- 치트 사용 이력이 있는 저장은 온라인 불가 +- 또는 온라인 전용 프로필/저장을 별도로 강제 + +초기 버전 기준으로는 **치트 오염 저장 온라인 불가**가 가장 단순하고 명확하다. + +## 성장 상태 위조를 막는 방법 + +완전한 방지는 어렵지만, 아래 조합으로 상당 부분 줄일 수 있다. + +- 등록 시 서버 검증 +- 등록 스냅샷 사용 +- 등록 이후 배틀 중 실시간 로컬 상태 참조 금지 +- ruleset 기준 필터링 +- 온라인 허용 저장 상태 검사 + +향후에는 추가로 다음을 고려할 수 있다. + +- 서버 서명 기반 registration proof +- 로컬 저장 해시/검증 +- 이벤트 기반 성장 로그 업로드 + +## 현실적인 절충 + +스토리/성장이 로컬에 있는 구조에서는 완전 무결성을 바로 달성하기 어렵다. +따라서 초기 친선 PvP의 현실적인 목표는 다음이다. + +- 결과 위조는 강하게 차단 +- 성장 상태 위조는 등록 단계에서 크게 억제 +- 남는 위험은 친선전 수준에서 수용 가능 범위로 축소 + +즉, 초기 PvP는 “완전 무결한 e스포츠 환경”이 아니라, **친선전으로 충분히 신뢰 가능한 수준**을 목표로 한다. + +## 온라인 진입 조건 제안 + +플레이어는 다음 조건을 만족해야 온라인 PvP에 진입할 수 있다. + +- 해당 세대의 활성 등록 파티가 존재함 +- 등록 파티가 현재 ruleset 검증을 통과함 +- 치트 오염 상태가 아님 +- 서버 인증 세션이 유효함 + +## 구현 우선순위 + +### 반드시 초기부터 넣어야 하는 것 +- 서버 권한 배틀 +- 등록 파티 스냅샷 +- 룰 검증 +- 치트 오염 저장 차단 +- 배틀 이벤트 로그 저장 + +### 추후 강화 가능한 것 +- 재접속 악용 방지 +- 부정행위 탐지 로그 분석 +- 관전/리플레이 무결성 보장 +- 서버 기반 성장 검증 강화 + +## 설계 결론 + +초기 PvP의 보안은 “무조건 모든 치트를 완벽히 막는다”가 아니라, **가장 치명적인 두 위조를 먼저 막는 구조를 고정한다**가 핵심이다. +그 구조의 중심은 다음 두 가지다. + +- 서버 권한 전투 +- 서버 검증 온라인 파티 등록 + +## 다음 문서 + +- [구현 로드맵](../roadmap/rollout-plan.md) +- [API 계약](../server/api-contract.md) diff --git a/docs/pvp/server/README.md b/docs/pvp/server/README.md new file mode 100644 index 00000000..b4eb4057 --- /dev/null +++ b/docs/pvp/server/README.md @@ -0,0 +1,39 @@ +# PvP 서버 설계 문서 + +상위 문서: [PvP 문서 인덱스](../README.md) + +이 섹션은 Tokénmon 온라인 PvP의 **서버 구조, 저장 모델, 통신 계약, 실시간 흐름**을 정의한다. +이번 정리에서는 서버 문서를 `개요`, `storage`, `contracts`로 한 단계 더 쪼개서 읽기 순서를 분명하게 만들었다. + +## 문서 구조 + +### 1. 서버 개요 +- [서버 아키텍처](./architecture.md) +- [API 계약](./api-contract.md) +- [실시간 배틀 흐름](./battle-flow.md) + +### 2. 저장 모델 +- [Storage 인덱스](./storage/README.md) + - [데이터 모델](./storage/data-model.md) + - [DB 스키마 초안](./storage/database-schema.md) + +### 3. 세부 계약 +- [Contracts 인덱스](./contracts/README.md) + - [온라인 파티 등록 상세 계약](./contracts/party-registration.md) + - [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md) + - [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md) + +## 권장 읽기 순서 + +1. 먼저 [서버 아키텍처](./architecture.md)로 왜 서버 권한 구조가 필요한지 본다. +2. 다음 [Storage 인덱스](./storage/README.md)로 저장 계층 문서를 따라간다. +3. 그 다음 [API 계약](./api-contract.md)으로 HTTP / WebSocket 표면을 훑는다. +4. 이후 [Contracts 인덱스](./contracts/README.md)로 Phase별 상세 계약을 따라간다. +5. 마지막으로 [실시간 배틀 흐름](./battle-flow.md)으로 실제 플레이 시퀀스를 다시 연결해서 본다. + +## 구현 전에 특히 먼저 볼 문서 + +- Phase 1 등록 작업 전: [온라인 파티 등록 상세 계약](./contracts/party-registration.md) +- Phase 2 룸 작업 전: [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md) +- Phase 3 배틀 세션 작업 전: [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md) +- 저장 계층 구현 전: [DB 스키마 초안](./storage/database-schema.md) diff --git a/docs/pvp/server/api-contract.md b/docs/pvp/server/api-contract.md new file mode 100644 index 00000000..3cf168d9 --- /dev/null +++ b/docs/pvp/server/api-contract.md @@ -0,0 +1,234 @@ +# PvP HTTP / WebSocket API 계약 초안 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [서버 아키텍처](./architecture.md), [서버 데이터 모델](./storage/data-model.md), [온라인 파티 등록 상세 계약](./contracts/party-registration.md), [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md), [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md), [실시간 배틀 흐름](./battle-flow.md), [치트 대응 정책](../security/anti-cheat.md) + +## 목표 + +이 문서는 초기 친선 PvP 구현을 위한 **최소 API 계약**을 정의한다. +핵심 원칙은 “REST로 준비하고, WebSocket으로 싸운다”이다. + +--- + +## HTTP API + +### 1. 현재 ruleset 조회 + +`GET /api/pvp/rulesets/{generation}` + +응답 예시: + +```json +{ + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "partySize": 6, + "teamPreview": false, + "speciesDupClause": true, + "legendaryMythicalLimit": 2, + "restrictedLimit": 1, + "effectiveLevelCap": 60, + "levelCompression": "soft-cap-after-50" +} +``` + +### 2. 세대별 활성 등록 파티 조회 + +`GET /api/pvp/parties/{generation}/active` + +### 3. 세대별 온라인 파티 등록/갱신 + +`PUT /api/pvp/parties/{generation}/active` + +상세 계약은 [온라인 파티 등록 상세 계약](./contracts/party-registration.md)을 따른다. + +요청 예시: + +```json +{ + "members": [ + { + "slot": 1, + "speciesId": "483", + "nickname": "Dialga", + "levelActual": 72, + "moves": ["dragon-claw", "flash-cannon", "rest", "roar-of-time"] + } + ], + "sourceStateHash": "sha256:..." +} +``` + +서버 검증 항목: + +- 종 중복 여부 +- 마릿수 규칙 +- legendary/mythical 총량 제한 +- restricted 제한 +- 레벨 정책 계산 가능 여부 +- 치트 오염 상태 여부 + +### 4. 친구전 룸 생성 + +`POST /api/pvp/rooms` + +상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md)을 따른다. + +요청 예시: + +```json +{ + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "visibility": "private_friend" +} +``` + +응답 예시: + +```json +{ + "roomId": "room_123", + "roomCode": "A7KQ2M", + "status": "waiting_for_opponent" +} +``` + +### 5. 룸 참가 + +`POST /api/pvp/rooms/{roomId}/join` + +상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md)을 따른다. + +요청 예시: + +```json +{ + "roomCode": "A7KQ2M", + "generation": "gen4" +} +``` + +### 6. 룸 상태 조회 + +`GET /api/pvp/rooms/{roomId}` + +상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md)을 따른다. + +재접속 시 초기 동기화에 사용한다. + +--- + +## WebSocket 연결 + +### 연결 + +`GET /ws/pvp?roomId=&token=` + +상세 계약은 [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md)을 따른다. + +클라이언트는 룸 입장 완료 후 WebSocket을 연결한다. + +### 클라이언트 -> 서버 메시지 + +#### `battle.command` + +```json +{ + "type": "battle.command", + "roomId": "room_123", + "turn": 4, + "command": { + "type": "choose_move", + "moveSlot": 2 + } +} +``` + +가능한 command type: + +- `choose_move` +- `choose_switch` +- `choose_replacement` +- `forfeit` + +### 서버 -> 클라이언트 메시지 + +#### `room.snapshot` + +재접속/최초 진입 시 현재 공개 상태를 내려준다. + +#### `battle.request_action` + +플레이어에게 행동을 요구한다. + +```json +{ + "type": "battle.request_action", + "roomId": "room_123", + "turn": 4, + "deadlineMs": 45000, + "request": { + "kind": "choose_move_or_switch", + "activePokemon": { "speciesId": "006", "hp": 121 }, + "availableMoves": [ + { "slot": 1, "id": "flamethrower" }, + { "slot": 2, "id": "slash" } + ], + "availableSwitches": [ + { "slot": 3, "speciesId": "143" } + ] + } +} +``` + +#### `battle.command_accepted` + +서버가 명령을 수락했음을 알린다. + +#### `battle.turn_resolved` + +해당 턴의 결과 이벤트 묶음을 내려준다. + +#### `battle.force_replacement` + +포켓몬 기절 후 다음 포켓몬 선택을 요구한다. + +#### `battle.ended` + +승패와 종료 사유를 알려준다. + +--- + +## 에러 원칙 + +HTTP와 WebSocket 모두 다음 종류의 에러를 분리하는 것이 좋다. + +- 인증 실패 +- ruleset 불일치 +- 파티 미등록 +- 파티 검증 실패 +- 이미 명령 제출됨 +- 현재 행동 가능 상태 아님 +- 타이머 만료 +- 룸 상태 불일치 + +## 숨은 정보 원칙 + +상대 백라인, 상대의 비공개 상세 상태 등은 서버가 보내지 않는다. +따라서 `room.snapshot`과 `battle.turn_resolved`는 **플레이어별 투영 결과**여야 한다. + +## 초기 API 결론 + +- 등록/룸 관리는 HTTP +- 실시간 턴 처리는 WebSocket +- 클라이언트는 명령만 보냄 +- 서버는 플레이어별 가시 정보만 내려줌 + +## 다음 문서 + +- [온라인 파티 등록 상세 계약](./contracts/party-registration.md) +- [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md) +- [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md) +- [실시간 배틀 흐름](./battle-flow.md) +- [치트 대응 정책](../security/anti-cheat.md) diff --git a/docs/pvp/server/architecture.md b/docs/pvp/server/architecture.md new file mode 100644 index 00000000..27d1cc19 --- /dev/null +++ b/docs/pvp/server/architecture.md @@ -0,0 +1,122 @@ +# PvP 서버 아키텍처 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [서버 데이터 모델](./storage/data-model.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md), [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md), [실시간 배틀 흐름](./battle-flow.md), [치트 대응 정책](../security/anti-cheat.md) + +## 핵심 결정 + +초기 온라인 PvP는 **서버 권한(server-authoritative)** 구조로 간다. + +즉: + +- 클라이언트는 명령만 보낸다. +- 서버가 배틀 로직 전체를 계산한다. +- 서버가 결과를 저장한다. +- 클라이언트는 서버 이벤트를 렌더링한다. + +이 결정은 Tokénmon PvP에서 가장 중요한 보안 요구사항 두 개를 직접 겨냥한다. + +1. 전투 결과 위조 방지 +2. 성장 상태 위조 최소화 + +## 왜 서버 권한이어야 하는가 + +만약 클라이언트가 결과를 계산하면 다음 문제가 생긴다. + +- 대미지 계산 위조 +- 승패 위조 +- 상태 이상/교체 흐름 위조 +- 경험치/보상 결과 위조 +- 로컬 저장 조작을 통한 파티 상태 위조 + +반면 서버 권한 구조에서는 클라이언트가 “이 턴에 무엇을 하겠다”만 제안하고, 실제 결과는 서버가 결정하므로 위조 가능성이 훨씬 줄어든다. + +## 권장 구성 요소 + +### 1. Party Registration Service + +- 세대별 온라인 파티 등록 +- 파티 유효성 검증 +- 치트 상태 검증 +- 활성 파티 갱신 + +### 2. Battle Room Service + +- 룸 생성/참가 +- 초대 코드 관리 +- 플레이어 입장 상태 관리 +- 세대 및 ruleset 확정 + +### 3. Battle Simulation Engine + +- 기술 선택 처리 +- 교체 처리 +- 속도/우선도/판정 +- 대미지 계산 +- 상태 변화 +- 승패 판정 + +### 4. Event Stream / Realtime Gateway + +- WebSocket 연결 유지 +- 턴 요청 이벤트 송신 +- 턴 결과 이벤트 송신 +- 재접속 처리 + +### 5. Persistence Layer + +- 등록 파티 스냅샷 저장 +- 룸 상태 저장 +- 배틀 커맨드 저장 +- 배틀 이벤트 로그 저장 + +## 통신 모델 + +권장 구조는 **HTTP + WebSocket 혼합**이다. + +- HTTP: 등록, 룸 생성, 룸 참가, 메타 조회 +- WebSocket: 실시간 턴 진행, 상태 동기화, 배틀 이벤트 수신 + +이 조합이 좋은 이유: + +- 초기 구현이 단순하다. +- 실시간성과 요청/응답형 API를 분리할 수 있다. +- 재접속/이벤트 스트림 관리가 용이하다. + +## 숨은 정보 처리 + +초기 PvP는 팀 프리뷰가 없고 상대 백라인도 비공개다. 따라서 서버는 플레이어별로 다른 정보를 내려줘야 한다. + +예: + +- 내 파티 전체 상태는 전부 보인다. +- 상대는 현재 필드에 나온 포켓몬 정보 중심으로만 보인다. +- 상대 백라인은 서버 내부에만 존재한다. + +즉, 서버는 단순 브로드캐스트가 아니라 **플레이어별 시야(view projection)** 를 만들어야 한다. + +## 재접속 고려 + +실시간 PvP에서는 네트워크 이슈가 반드시 생긴다. 초기 버전에서도 최소한 다음은 고려해야 한다. + +- 룸 상태 복구 +- 마지막 공개 정보 재전송 +- 남은 선택 시간 재계산 +- 이미 제출한 명령 여부 확인 + +## 아키텍처 결론 + +초기 PvP 서버는 다음 원칙으로 구현한다. + +- 서버가 유일한 정답 소스(source of truth) +- 클라이언트는 입력 장치 + 렌더러 +- 숨은 정보는 서버에서 플레이어별로 분리 투영 +- 룸/파티/배틀 로그를 데이터 모델로 명확히 분리 + +## 다음 문서 + +- [서버 데이터 모델](./storage/data-model.md) +- [API 계약](./api-contract.md) +- [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md) +- [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md) +- [실시간 배틀 흐름](./battle-flow.md) diff --git a/docs/pvp/server/battle-flow.md b/docs/pvp/server/battle-flow.md new file mode 100644 index 00000000..2cae8a22 --- /dev/null +++ b/docs/pvp/server/battle-flow.md @@ -0,0 +1,139 @@ +# 실시간 배틀 흐름 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [배틀 포맷](../game-design/battle-format.md), [서버 아키텍처](./architecture.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md), [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md) + +## 목표 + +이 문서는 실제 플레이어 경험 기준으로, 온라인 친선 PvP가 어떤 순서로 진행되는지 정의한다. +필드별 payload와 오류 코드는 [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md) 및 [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md)을 따른다. + +## 전체 흐름 + +### 1. 파티 등록 완료 + +양 플레이어는 해당 세대에 대해 활성 온라인 파티를 등록해 둔다. + +### 2. 룸 생성 / 참가 + +- 플레이어 A가 친구전 룸 생성 +- room code 발급 +- 플레이어 B가 코드로 참가 +- 서버가 양측 파티와 ruleset 유효성 확인 +- 양측 binding이 끝나면 룸은 `awaiting_presence` 상태로 전이 + +### 3. 배틀 시작 + +- 양측이 실시간 세션에 연결되면 룸은 `starting`으로 전이 +- 서버가 양측 파티의 **1번 슬롯**을 선발로 고정 +- 상대 백라인은 비공개 +- 최초 공개 상태 스냅샷(`room.snapshot`) 송신 + +### 4. 행동 선택 단계 + +각 턴마다 서버는 양측에 행동 요청을 보낸다. + +가능 행동: + +- 기술 선택 +- 교체 선택 +- 항복 + +### 5. 서버 계산 + +양측 명령이 수집되면 서버가 다음을 계산한다. + +- 우선순위 +- 속도 순서 +- 행동 유효성 +- 대미지 +- 상태 변화 +- 쓰러짐 여부 +- 승패 여부 + +### 6. 턴 결과 송신 + +서버는 결과 이벤트 묶음을 순서대로 내려준다. + +예: + +- 기술 사용 +- 피해 발생 +- 상태 이상 적용 +- 포켓몬 기절 +- 승패 체크 + +### 7. 교체/후속 선택 단계 + +기절한 포켓몬이 있으면 해당 플레이어에게만 `다음 포켓몬 선택` 요청을 보낸다. + +### 8. 종료 + +한쪽 파티가 전멸하거나 항복하면 서버가 종료 이벤트를 보낸다. + +--- + +## 상태 머신 관점 + +```text +waiting_for_opponent + -> awaiting_presence + -> starting + -> in_progress + -> awaiting_actions + -> resolving_turn + -> awaiting_replacement + -> resolving_turn + -> finished +``` + +## 턴 처리 원칙 + +### 선택 타이머 + +- 기본 선택 시간: 45초 +- 시간 초과 시 정책은 추후 확정 가능하지만, 초기안은 다음 둘 중 하나로 설계할 수 있다. + - 기본 기술 자동 선택 + - 즉시 패배/실격 + +초기 친선전에서는 지나치게 가혹하지 않게 **기본 행동 대체**가 UX상 더 나을 가능성이 높다. 다만 구현 난이도와 악용 가능성은 따져야 한다. + +### 교체 요청 + +기절 직후에는 해당 플레이어만 행동 가능하다. 이 단계는 일반 턴 선택과 분리된 별도 요청으로 다루는 편이 안전하다. + +### 숨은 정보 유지 + +- 턴 결과 이벤트는 양 플레이어에게 동일하지 않을 수 있다. +- 상대 백라인 관련 정보는 절대 조기 공개하지 않는다. + +## 최소 커맨드 타입 + +- `choose_move` +- `choose_switch` +- `choose_replacement` +- `forfeit` + +## 최소 이벤트 타입 + +- `battle_started` +- `action_requested` +- `command_accepted` +- `move_used` +- `damage_applied` +- `status_applied` +- `pokemon_fainted` +- `replacement_requested` +- `battle_ended` + +## 설계 결론 + +실시간 PvP는 턴제이지만, 네트워크 구조는 **이벤트 기반 실시간 시스템**으로 봐야 한다. +즉, “한 턴 입력 → 서버 계산 → 이벤트 스트림 반영”이 기본 루프다. + +## 다음 문서 + +- [API 계약](./api-contract.md) +- [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md) +- [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md) +- [치트 대응 정책](../security/anti-cheat.md) diff --git a/docs/pvp/server/contracts/README.md b/docs/pvp/server/contracts/README.md new file mode 100644 index 00000000..d08d6e75 --- /dev/null +++ b/docs/pvp/server/contracts/README.md @@ -0,0 +1,24 @@ +# PvP 서버 Contracts 문서 + +상위 문서: [서버 설계 인덱스](../README.md) + +이 섹션은 서버가 외부에 노출하는 **Phase별 상세 계약**을 모아둔 하위 묶음이다. +즉, 실제 구현 시 “어떤 endpoint / 메시지 / 상태 전이가 허용되는가”를 필드 단위로 따라갈 때 읽는 문서들이다. + +## 포함 문서 + +1. [온라인 파티 등록 상세 계약](./party-registration.md) +2. [친구전 룸 / 매치 성립 상세 계약](./room-and-match.md) +3. [실시간 배틀 세션 상세 계약](./realtime-battle-session.md) + +## 읽는 순서 + +- Phase 1: [온라인 파티 등록 상세 계약](./party-registration.md) +- Phase 2: [친구전 룸 / 매치 성립 상세 계약](./room-and-match.md) +- Phase 3: [실시간 배틀 세션 상세 계약](./realtime-battle-session.md) + +## 이 묶음이 답하는 질문 + +- 어떤 입력을 서버가 받아들이고 어떤 입력을 거절하는가? +- 룸 생성/참가/시작 준비는 어떤 상태 머신으로 묶이는가? +- WebSocket 세션에서 어떤 이벤트를 어떤 공개 범위로 내려야 하는가? diff --git a/docs/pvp/server/contracts/party-registration.md b/docs/pvp/server/contracts/party-registration.md new file mode 100644 index 00000000..94149d81 --- /dev/null +++ b/docs/pvp/server/contracts/party-registration.md @@ -0,0 +1,633 @@ +# 온라인 파티 등록 상세 계약 + +상위 문서: [PvP 서버 Contracts 문서](./README.md) +관련 문서: [API 계약 초안](../api-contract.md), [서버 데이터 모델](../storage/data-model.md), [DB 스키마 초안](../storage/database-schema.md), [성장 및 파티 등록](../../game-design/progression-and-party-registration.md), [치트 대응 정책](../../security/anti-cheat.md) + +## 목적 + +이 문서는 초기 PvP의 **Phase 1 계약**을 상세화한다. +범위는 다음 세 가지다. + +1. `GET /api/pvp/rulesets/{generation}` +2. `GET /api/pvp/parties/{generation}/active` +3. `PUT /api/pvp/parties/{generation}/active` + +즉, “이 세대에서 어떤 룰로 싸우는가”와 “내가 지금 온라인에서 사용할 파티가 무엇인가”를 서버 기준으로 확정하는 계약이다. + +--- + +## 범위 밖 + +이 문서는 아직 아래를 다루지 않는다. + +- 룸 생성 / 참가 +- WebSocket 실시간 배틀 명령 +- 배틀 종료 후 보상 지급 +- 서버 주도 성장 전체 이관 + +--- + +## 계약 원칙 + +### 1. 서버가 온라인 사용 가능 여부를 최종 판단한다 +클라이언트는 로컬 저장에서 파티 후보를 읽어 올 수는 있지만, **온라인에 실제로 사용 가능한 파티**는 서버가 검증 후 승인한 활성 스냅샷뿐이다. + +### 2. 등록은 전체 교체(full replace)다 +`PUT /active`는 부분 수정이 아니라, 해당 세대의 활성 파티 전체를 새 스냅샷으로 교체하는 연산이다. + +### 3. 클라이언트는 계산 결과를 보내지 않는다 +클라이언트는 `levelEffective`, 특수 포켓몬 분류 판정, 제한 카운트, 치트 판정 결과를 보내지 않는다. 이 값들은 모두 서버가 계산한다. + +### 4. 로컬 성장의 감성은 보존하되, 온라인 진입은 서버 스냅샷으로 고정한다 +스토리/로컬에서 키운 파티를 가져오되, 온라인 전투 중에는 로컬 저장이 아니라 **등록 시점 스냅샷**만 사용한다. + +--- + +## 공통 전송 규칙 + +- 프로토콜: HTTPS + JSON +- 인코딩: UTF-8 +- 시간 포맷: RFC 3339 / ISO-8601 UTC 문자열 +- 인증: `Authorization: Bearer ` 가정 +- `generation`은 path parameter로만 받는다. 별도 body 중복 필드는 허용하지 않는다. +- 모든 성공 응답은 최소한 `generation`, `rulesetKey` 또는 `snapshotId`처럼 **현재 서버가 확정한 식별자**를 포함한다. +- 모든 실패 응답은 아래 `error` envelope를 사용한다. + +### 공통 에러 envelope + +```json +{ + "error": { + "code": "PVP_SPECIES_DUPLICATE", + "message": "Duplicate species are not allowed in an online party.", + "retryable": false, + "details": { + "generation": "gen4", + "field": "members[3].speciesId" + } + } +} +``` + +### 공통 에러 필드 + +| 필드 | 설명 | +|---|---| +| `code` | 기계 판독용 안정 식별자 | +| `message` | 사용자/로그용 설명 | +| `retryable` | 같은 입력으로 즉시 재시도 가치가 있는지 | +| `details` | 필드 오류, 제한 초과 수치, 현재 ruleset key 등 부가 정보 | + +--- + +## 공유 객체 계약 + +## RulesetSummary + +```json +{ + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "status": "active", + "party": { + "size": 6, + "activePartySlotsPerPlayer": 1, + "speciesDupClause": true + }, + "specialLimits": { + "legendaryMythicalTotal": 2, + "restrictedTotal": 1 + }, + "levelPolicy": { + "displayMode": "actual-level-visible", + "effectiveFormulaKey": "soft-cap-after-50-v1", + "softCapStartsAt": 50, + "effectiveLevelCap": 60 + }, + "battlePolicy": { + "format": "single", + "teamPreview": false, + "leadSelection": "slot1_auto", + "replacementSelection": "manual", + "actionTimeoutSeconds": 45 + }, + "cheatPolicy": { + "requireCleanSave": true, + "allowCheatFlaggedSave": false, + "growthSnapshotRequired": true + }, + "updatedAt": "2026-04-11T06:00:00Z" +} +``` + +### 의미 + +- `activePartySlotsPerPlayer`: 초기 버전에서는 세대당 활성 온라인 파티 1개만 허용한다. +- `effectiveFormulaKey`: 실제 계산식 자체를 하드코딩 문자열로 박기보다, **버전 가능한 정책 키**로 고정한다. +- `specialLimits`: `legendary`, `mythical`, `restricted` 분류는 서버 정책 데이터가 최종 기준이다. + +--- + +## OnlinePartyMemberInput + +`PUT /active` 요청의 `members[]` 요소 계약이다. + +```json +{ + "slot": 1, + "pokemonInstanceId": "pkm_8f0d2f7a", + "speciesId": "483", + "nickname": "Dialga", + "levelActual": 72, + "moves": ["dragon-claw", "flash-cannon", "rest", "roar-of-time"] +} +``` + +### 필드 규칙 + +| 필드 | 타입 | 규칙 | +|---|---|---| +| `slot` | integer | 1~6, 중복 불가 | +| `pokemonInstanceId` | string | 로컬 저장에서 해당 개체를 식별하는 안정 ID | +| `speciesId` | string | 서버 도감 기준 species 식별자 | +| `nickname` | string | 선택, 서버가 길이/금칙문자 정규화 가능 | +| `levelActual` | integer | 1 이상, 서버가 로컬 스냅샷과 대조 | +| `moves` | string[] | 정확히 4개를 기본 가정, 중복/존재/세대 적합성은 서버 검증 | + +### 입력 금지 필드 + +클라이언트는 다음 값을 보내지 않는다. + +- `levelEffective` +- `isLegendary` +- `isMythical` +- `isRestricted` +- `cheatStatus` +- `currentHp` +- `statusCondition` +- `battleStatStage` + +이 값들은 모두 서버 계산/판정 대상이다. + +--- + +## GrowthProofInput + +초기 버전은 로컬 저장 전체를 신뢰하지 않기 때문에, 등록 요청에는 **최소 성장 증빙 메타데이터**가 포함되어야 한다. + +```json +{ + "proofVersion": "v1", + "capturedAt": "2026-04-11T06:12:00Z", + "sourceSaveId": "save_main", + "sourceSaveRevision": 184, + "cheatFlags": { + "hasCheatHistory": false, + "flags": [] + }, + "memberProofs": [ + { + "slot": 1, + "pokemonInstanceId": "pkm_8f0d2f7a", + "speciesId": "483", + "levelActual": 72, + "movesHash": "sha256:3a4f...", + "stateHash": "sha256:8d10..." + } + ] +} +``` + +### 최소 요구 의도 + +초기 버전에서 이것은 “완전한 암호학적 증명”이 아니다. +대신 서버가 다음을 확인할 수 있게 해 주는 **등록 시점 일관성 메타데이터**다. + +1. 어떤 저장 기준에서 읽었는가 +2. 그 저장 revision이 무엇인가 +3. 치트 오염 플래그가 있었는가 +4. 각 엔트리가 어떤 개체/상태 해시를 기반으로 추출되었는가 + +### 서버 해석 원칙 + +- `proofVersion`이 모르면 등록 거부 +- `cheatFlags.hasCheatHistory == true`면 기본 거부 +- `memberProofs`는 `members`와 slot / instanceId 기준으로 일치해야 함 +- `movesHash`, `stateHash`는 서버가 현재 지원하는 검증 수준에 따라 비교하거나 저장만 할 수 있음 +- 초기 버전은 “검증 가능한 만큼 검증하고, 검증 불가 항목은 저장 후 감사 가능하게 남기는” 전략을 쓴다 + +--- + +## OnlinePartySnapshot + +서버가 확정해 저장/응답하는 활성 파티 스냅샷이다. + +```json +{ + "snapshotId": "ops_gen4_000123", + "snapshotVersion": 3, + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "status": "active", + "registeredAt": "2026-04-11T06:12:03Z", + "sourceStateHash": "sha256:0bb1...", + "sourceConfigHash": "sha256:17cc...", + "validationStatus": "accepted", + "partySummary": { + "memberCount": 6, + "legendaryMythicalCount": 2, + "restrictedCount": 1, + "speciesDupClause": true + }, + "members": [ + { + "slot": 1, + "pokemonInstanceId": "pkm_8f0d2f7a", + "speciesId": "483", + "nickname": "Dialga", + "levelActual": 72, + "levelEffective": 54, + "specialClass": { + "legendary": true, + "mythical": false, + "restricted": true + }, + "moves": ["dragon-claw", "flash-cannon", "rest", "roar-of-time"] + } + ] +} +``` + +### 서버 생성 필드 + +다음 값은 클라이언트 입력이 아니라 서버가 생성한다. + +- `snapshotId` +- `snapshotVersion` +- `validationStatus` +- `partySummary.*` +- `members[].levelEffective` +- `members[].specialClass` + +--- + +## Endpoint 1. Ruleset 조회 + +### 요청 + +`GET /api/pvp/rulesets/{generation}` + +### 성공 응답 + +`200 OK` + +응답 body는 [`RulesetSummary`](#rulesetsummary)와 같다. + +### 실패 케이스 + +| HTTP | code | 의미 | +|---|---|---| +| 401 | `PVP_UNAUTHORIZED` | 로그인/세션 없음 | +| 404 | `PVP_RULESET_NOT_FOUND` | 해당 세대 ruleset 없음 | +| 410 | `PVP_RULESET_DISABLED` | 세대는 존재하지만 현재 비활성 | + +### 메모 + +이 endpoint는 클라이언트가 파티 등록 UI를 그리기 전에 반드시 호출하는 것을 권장한다. +즉, 등록 계약의 기준은 **클라이언트 하드코딩이 아니라 서버 ruleset**이다. + +--- + +## Endpoint 2. 활성 파티 조회 + +### 요청 + +`GET /api/pvp/parties/{generation}/active` + +### 성공 응답 + +`200 OK` + +```json +{ + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "party": { + "snapshotId": "ops_gen4_000123", + "snapshotVersion": 3, + "status": "active", + "registeredAt": "2026-04-11T06:12:03Z", + "sourceStateHash": "sha256:0bb1...", + "sourceConfigHash": "sha256:17cc...", + "validationStatus": "accepted", + "partySummary": { + "memberCount": 6, + "legendaryMythicalCount": 2, + "restrictedCount": 1, + "speciesDupClause": true + }, + "members": [ + { + "slot": 1, + "pokemonInstanceId": "pkm_8f0d2f7a", + "speciesId": "483", + "nickname": "Dialga", + "levelActual": 72, + "levelEffective": 54, + "specialClass": { + "legendary": true, + "mythical": false, + "restricted": true + }, + "moves": ["dragon-claw", "flash-cannon", "rest", "roar-of-time"] + } + ] + } +} +``` + +### 실패 케이스 + +| HTTP | code | 의미 | +|---|---|---| +| 401 | `PVP_UNAUTHORIZED` | 로그인/세션 없음 | +| 404 | `PVP_ACTIVE_PARTY_NOT_FOUND` | 해당 세대의 활성 파티 미등록 | +| 409 | `PVP_RULESET_MISMATCH` | 현재 활성 파티가 더 이상 유효하지 않은 과거 ruleset에 묶여 있음 | + +### 메모 + +- `404`는 에러이지만 UX 관점에서는 “아직 등록 안 함” 상태로 취급 가능하다. +- 서버는 필요하면 과거 snapshot을 유지하더라도, 이 endpoint는 항상 **현재 활성 1개**만 반환한다. + +--- + +## Endpoint 3. 활성 파티 등록 / 갱신 + +### 요청 + +`PUT /api/pvp/parties/{generation}/active` + +```json +{ + "sourceStateHash": "sha256:0bb1...", + "sourceConfigHash": "sha256:17cc...", + "clientBuild": "tokenmon-cli/0.120.0", + "members": [ + { + "slot": 1, + "pokemonInstanceId": "pkm_8f0d2f7a", + "speciesId": "483", + "nickname": "Dialga", + "levelActual": 72, + "moves": ["dragon-claw", "flash-cannon", "rest", "roar-of-time"] + }, + { + "slot": 2, + "pokemonInstanceId": "pkm_6af1a611", + "speciesId": "491", + "nickname": "Darkrai", + "levelActual": 63, + "moves": ["dark-pulse", "hypnosis", "dream-eater", "double-team"] + } + ], + "growthProof": { + "proofVersion": "v1", + "capturedAt": "2026-04-11T06:12:00Z", + "sourceSaveId": "save_main", + "sourceSaveRevision": 184, + "cheatFlags": { + "hasCheatHistory": false, + "flags": [] + }, + "memberProofs": [ + { + "slot": 1, + "pokemonInstanceId": "pkm_8f0d2f7a", + "speciesId": "483", + "levelActual": 72, + "movesHash": "sha256:3a4f...", + "stateHash": "sha256:8d10..." + }, + { + "slot": 2, + "pokemonInstanceId": "pkm_6af1a611", + "speciesId": "491", + "levelActual": 63, + "movesHash": "sha256:6d28...", + "stateHash": "sha256:113f..." + } + ] + } +} +``` + +### 요청 필드 규칙 + +| 필드 | 규칙 | +|---|---| +| `sourceStateHash` | 로컬 저장 상태 스냅샷 해시. 동일 저장 기반 여부 확인에 사용 | +| `sourceConfigHash` | 로컬 설정/룰 관련 구성 해시. 호환성 추적용 | +| `clientBuild` | 선택. 디버깅/운영 추적용 | +| `members` | 정확히 6마리 요구 | +| `growthProof` | 필수. 최소 `proofVersion`, `capturedAt`, `sourceSaveRevision`, `cheatFlags`, `memberProofs` 필요 | + +### 서버 검증 순서 + +1. 인증/유저 식별 확인 +2. `generation`에 대한 활성 ruleset 확인 +3. payload shape 검증 +4. `members` 정확히 6마리인지 확인 +5. `slot`이 1~6 유일한지 확인 +6. `speciesId` 중복 금지 확인 +7. `members`와 `growthProof.memberProofs` 일치 확인 +8. 치트 플래그/오염 저장 여부 확인 +9. 세대/기술셋/개체 상태의 최소 합법성 확인 +10. `legendary + mythical <= 2` 확인 +11. `restricted <= 1` 확인 +12. `levelEffective` 계산 +13. 기존 활성 snapshot과 동일 입력인지 비교 +14. 필요 시 새 snapshot version 생성 후 트랜잭션으로 active 교체 + +--- + +## 등록 성공 응답 + +### 1. 새 스냅샷 생성됨 + +`200 OK` + +```json +{ + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "changed": true, + "party": { + "snapshotId": "ops_gen4_000124", + "snapshotVersion": 4, + "status": "active", + "registeredAt": "2026-04-11T06:15:05Z", + "sourceStateHash": "sha256:0bb1...", + "sourceConfigHash": "sha256:17cc...", + "validationStatus": "accepted", + "partySummary": { + "memberCount": 6, + "legendaryMythicalCount": 2, + "restrictedCount": 1, + "speciesDupClause": true + }, + "members": [ + { + "slot": 1, + "pokemonInstanceId": "pkm_8f0d2f7a", + "speciesId": "483", + "nickname": "Dialga", + "levelActual": 72, + "levelEffective": 54, + "specialClass": { + "legendary": true, + "mythical": false, + "restricted": true + }, + "moves": ["dragon-claw", "flash-cannon", "rest", "roar-of-time"] + } + ] + } +} +``` + +### 2. 입력은 같고 변경 없음 + +`200 OK` + +```json +{ + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "changed": false, + "party": { + "snapshotId": "ops_gen4_000124", + "snapshotVersion": 4, + "status": "active", + "registeredAt": "2026-04-11T06:15:05Z", + "validationStatus": "accepted" + } +} +``` + +### 멱등성 원칙 + +- 같은 정규화 결과와 같은 source hash로 재등록하면 `changed: false`를 반환할 수 있다. +- 다른 입력이면 반드시 새 `snapshotVersion`을 발급한다. +- 초기 버전은 히스토리 보존을 위해 **update in place보다 append + active 전환**을 권장한다. + +--- + +## 등록 실패 계약 + +### 대표 에러 코드 + +| HTTP | code | 설명 | +|---|---|---| +| 400 | `PVP_INVALID_REQUEST` | JSON shape 자체가 잘못됨 | +| 401 | `PVP_UNAUTHORIZED` | 인증 실패 | +| 403 | `PVP_CHEAT_CONTAMINATED_SAVE` | 치트 오염 저장이라 온라인 등록 불가 | +| 404 | `PVP_RULESET_NOT_FOUND` | 세대 ruleset 없음 | +| 409 | `PVP_SOURCE_HASH_STALE` | 클라이언트가 기준으로 삼은 로컬 상태가 이미 바뀌었음 | +| 409 | `PVP_RULESET_CHANGED` | 등록 도중 서버 ruleset이 바뀌어 재확인 필요 | +| 422 | `PVP_PARTY_SIZE_INVALID` | 6마리 조건 위반 | +| 422 | `PVP_PARTY_SLOT_DUPLICATED` | slot 중복 | +| 422 | `PVP_SPECIES_DUPLICATE` | 동일 종 중복 | +| 422 | `PVP_SPECIAL_LIMIT_EXCEEDED` | legendary + mythical 총량 초과 | +| 422 | `PVP_RESTRICTED_LIMIT_EXCEEDED` | restricted 초과 | +| 422 | `PVP_MEMBER_NOT_OWNED` | 해당 개체가 로컬 스냅샷에 존재하지 않음 | +| 422 | `PVP_MEMBER_STATE_MISMATCH` | 레벨/기술/상태 hash가 증빙과 불일치 | +| 422 | `PVP_MOVESET_INVALID` | 기술 조합이 세대 또는 정책에 맞지 않음 | +| 422 | `PVP_GROWTH_PROOF_INVALID` | growth proof 자체가 불완전하거나 버전 미지원 | + +### 필드 오류 예시 + +```json +{ + "error": { + "code": "PVP_RESTRICTED_LIMIT_EXCEEDED", + "message": "Restricted Pokémon limit exceeded.", + "retryable": false, + "details": { + "generation": "gen4", + "restrictedLimit": 1, + "restrictedDetected": 2, + "speciesIds": ["483", "484"] + } + } +} +``` + +--- + +## 서버 저장 계약 + +이 요청이 성공하면 서버는 최소한 다음을 저장한다. + +- `online_party_snapshots` + - `snapshot_id` + - `player_id` + - `generation` + - `ruleset_key` + - `snapshot_version` + - `is_active` + - `source_state_hash` + - `source_config_hash` + - `growth_proof_json` + - `validation_status` +- `online_party_members` + - `snapshot_id` + - `slot_index` + - `pokemon_instance_id` + - `species_id` + - `level_actual` + - `level_effective` + - `special_tags_json` + - `moves_json` + +즉, 배틀 시작 이후에는 로컬 저장을 다시 보지 않고도 서버 스냅샷만으로 전투 진입이 가능해야 한다. + +--- + +## 클라이언트 구현 메모 + +### 등록 화면 진입 전 +1. 먼저 `GET /rulesets/{generation}` 호출 +2. 해당 ruleset으로 제한/설명 문구 렌더링 +3. 현재 등록 상태를 `GET /parties/{generation}/active`로 확인 + +### 등록 시 +1. 로컬 저장에서 후보 6마리 선택 +2. `sourceStateHash`, `growthProof` 생성 +3. `PUT /active` 호출 +4. 성공 시 서버 반환 snapshot을 로컬 UI 기준 상태로 채택 + +### 실패 시 UX +- `404 PVP_ACTIVE_PARTY_NOT_FOUND`: “아직 온라인 파티가 등록되지 않았어요.” +- `403 PVP_CHEAT_CONTAMINATED_SAVE`: “치트 사용 이력이 있는 저장은 온라인에 등록할 수 없어요.” +- `422 PVP_SPECIES_DUPLICATE`: “같은 종의 포켓몬은 중복 등록할 수 없어요.” +- `422 PVP_SPECIAL_LIMIT_EXCEEDED`: “전설/환상은 총 2마리까지만 등록할 수 있어요.” +- `422 PVP_RESTRICTED_LIMIT_EXCEEDED`: “최상위 restricted 포켓몬은 1마리까지만 등록할 수 있어요.” + +--- + +## Phase 1 결론 + +초기 PvP에서 중요한 것은 “내 로컬 파티를 그대로 믿고 즉석에서 싸우게 하는 것”이 아니다. +더 중요한 것은 다음 두 가지다. + +1. **서버가 온라인에 쓸 파티를 사전에 승인한다.** +2. **승인된 스냅샷만 다음 단계(룸/배틀)에서 사용한다.** + +이 계약을 먼저 단단히 해 두면, 이후 룸 생성/참가와 실시간 배틀은 모두 이 스냅샷 식별자를 기준으로 안정적으로 연결할 수 있다. + +## 다음 문서 + +- [API 계약 초안](../api-contract.md) +- [친구전 룸 / 매치 성립 상세 계약](./room-and-match.md) +- [실시간 배틀 흐름](../battle-flow.md) +- [PvP 작업 분해 / TODO](../../implementation/todo-breakdown.md) diff --git a/docs/pvp/server/contracts/realtime-battle-session.md b/docs/pvp/server/contracts/realtime-battle-session.md new file mode 100644 index 00000000..f64b90cd --- /dev/null +++ b/docs/pvp/server/contracts/realtime-battle-session.md @@ -0,0 +1,638 @@ +# 실시간 배틀 세션 상세 계약 + +상위 문서: [PvP 서버 Contracts 문서](./README.md) +관련 문서: [API 계약 초안](../api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match.md), [실시간 배틀 흐름](../battle-flow.md), [치트 대응 정책](../../security/anti-cheat.md) + +## 목적 + +이 문서는 초기 PvP의 **Phase 3 계약**을 상세화한다. +범위는 다음 두 가지다. + +1. `GET /ws/pvp?roomId=&token=` 연결 규칙 +2. WebSocket 상에서 오가는 배틀 명령 / 이벤트 상세 계약 + +즉, “클라이언트는 무엇을 보낼 수 있고, 서버는 어떤 공개 상태만 내려주며, 턴제 전투를 어떻게 실시간 세션으로 운영하는가”를 세밀하게 정의한다. + +--- + +## 범위 밖 + +이 문서는 아직 아래를 다루지 않는다. + +- damage formula 내부 수학식 전체 +- spectator / replay 스트림 +- 래더 매치메이킹 +- 음성/채팅/이모트 +- 배틀 종료 후 보상 분배 + +--- + +## 계약 원칙 + +### 1. 클라이언트는 명령만 보내고 결과는 계산하지 않는다 +클라이언트는 `choose_move`, `choose_switch`, `choose_replacement`, `forfeit` 같은 **의도(intent)** 만 보낸다. +명중, 우선순위, 속도, 대미지, 상태 변화, 승패는 모두 서버가 계산한다. + +### 2. 서버 이벤트는 플레이어별 투영 결과다 +동일 턴이라도 각 플레이어가 보는 payload는 달라질 수 있다. +특히 상대 백라인, 숨은 기술 세부, 비공개 상태는 조기 공개하지 않는다. + +### 3. 배틀 시작 시 선발은 자동 고정이다 +초기 버전은 팀 프리뷰가 없고, 각자 **등록 파티 1번 슬롯이 자동 선발**이다. + +### 4. 턴 요청과 교체 요청은 별도 phase다 +일반 턴의 선택 요청과, 기절 후 replacement 요청은 서버 상태상 서로 다른 phase로 분리한다. + +### 5. 재접속은 “현재 공개 상태 복구” 기준으로 처리한다 +클라이언트가 끊겼다가 돌아오더라도, 서버는 **가장 최신 authoritative 공개 상태**를 다시 내려준다. 클라이언트 로컬 계산 복원에 의존하지 않는다. + +--- + +## 세션 상태 머신 + +```text +awaiting_presence + -> starting + -> awaiting_actions + -> resolving_turn + -> awaiting_replacement + -> resolving_turn + -> finished + -> abandoned +``` + +### 상태 의미 + +| 상태 | 의미 | +|---|---| +| `awaiting_presence` | 양측 WebSocket 접속 대기 | +| `starting` | battle start snapshot / seed / request 생성 중 | +| `awaiting_actions` | 일반 턴 명령 입력 대기 | +| `resolving_turn` | 서버 계산 중 | +| `awaiting_replacement` | 기절한 측의 교체 선택 대기 | +| `finished` | 승패 확정 | +| `abandoned` | 재접속 실패 등으로 운영상 중단 | + +--- + +## 연결 규칙 + +## `GET /ws/pvp?roomId=&token=` + +### 핸드셰이크 검증 순서 + +1. 토큰 인증 +2. `roomId` 존재 여부 확인 +3. 사용자가 room host/guest 중 하나인지 확인 +4. 룸 상태가 `awaiting_presence`, `starting`, `in_progress` 계열인지 확인 +5. 현재 seat의 기존 연결이 있으면 새 연결로 세션 교체 또는 중복 거부 정책 적용 +6. seat presence를 `connected`로 반영 +7. 서버가 `room.snapshot`을 즉시 전송 + +### 연결 거부 대표 코드 + +- `PVP_WS_UNAUTHORIZED` +- `PVP_ROOM_NOT_FOUND` +- `PVP_ROOM_ACCESS_DENIED` +- `PVP_ROOM_NOT_JOINABLE` +- `PVP_WS_DUPLICATE_CONNECTION` + +--- + +## 공통 메시지 envelope + +클라이언트/서버 메시지는 모두 최소한 아래 envelope를 따른다. + +```json +{ + "type": "battle.request_action", + "roomId": "room_01JZ6Y4P4R", + "battleId": "battle_01JZ6Y8MK2", + "seq": 14, + "sentAt": "2026-04-11T07:15:10.123Z", + "payload": {} +} +``` + +### 공통 필드 + +| 필드 | 설명 | +|---|---| +| `type` | 이벤트/명령 타입 | +| `roomId` | 룸 식별자 | +| `battleId` | 배틀 식별자 | +| `seq` | 서버 또는 클라이언트 발신 순서 식별자 | +| `sentAt` | 서버/클라이언트 발신 시간 | +| `payload` | 타입별 본문 | + +### 순서 원칙 + +- 서버 이벤트 `seq`는 같은 배틀 안에서 단조 증가해야 한다. +- 클라이언트 명령은 `clientCommandId`를 추가해 중복 제출 감지를 가능하게 한다. + +--- + +## 클라이언트 -> 서버 명령 계약 + +## `battle.command` + +```json +{ + "type": "battle.command", + "roomId": "room_01JZ6Y4P4R", + "battleId": "battle_01JZ6Y8MK2", + "seq": 5, + "sentAt": "2026-04-11T07:15:08.001Z", + "payload": { + "clientCommandId": "cmd_01JZ6Y8W6M", + "turn": 4, + "phase": "awaiting_actions", + "command": { + "type": "choose_move", + "moveSlot": 2 + } + } +} +``` + +### 공통 규칙 + +| 필드 | 규칙 | +|---|---| +| `clientCommandId` | 클라이언트가 생성하는 멱등 키 | +| `turn` | 현재 요청 턴과 일치해야 함 | +| `phase` | `awaiting_actions` 또는 `awaiting_replacement` | +| `command.type` | 허용 타입 중 하나여야 함 | + +### 허용 command type + +#### 1. `choose_move` + +```json +{ + "type": "choose_move", + "moveSlot": 2 +} +``` + +- 현재 active Pokémon이 보유한 기술 슬롯이어야 함 +- PP 0, 봉인, 상태상 사용 불가면 거부 가능 + +#### 2. `choose_switch` + +```json +{ + "type": "choose_switch", + "targetSlot": 4 +} +``` + +- 살아 있는 백라인이어야 함 +- 현재 교체 불가 상태면 거부 가능 + +#### 3. `choose_replacement` + +```json +{ + "type": "choose_replacement", + "targetSlot": 5 +} +``` + +- 기절 직후 replacement phase에서만 허용 +- 이미 쓰러졌거나 현재 필드 위인 slot은 불가 + +#### 4. `forfeit` + +```json +{ + "type": "forfeit" +} +``` + +- 언제든 허용 가능하지만, 이미 종료된 배틀이면 무시/거부 + +--- + +## 서버 -> 클라이언트 이벤트 계약 + +## 1. `room.snapshot` + +최초 접속 또는 재접속 시 현재 공개 상태 전체를 내려준다. + +```json +{ + "type": "room.snapshot", + "roomId": "room_01JZ6Y4P4R", + "battleId": "battle_01JZ6Y8MK2", + "seq": 1, + "sentAt": "2026-04-11T07:15:00.000Z", + "payload": { + "roomStatus": "starting", + "battleStatus": "awaiting_actions", + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "yourSeat": "host", + "turn": 1, + "visibleState": { + "self": { + "active": { + "slot": 1, + "speciesId": "006", + "nickname": "Blaze", + "levelActual": 63, + "levelEffective": 56, + "hp": 158, + "hpMax": 158, + "status": null, + "moves": [ + { "slot": 1, "id": "flamethrower", "disabled": false }, + { "slot": 2, "id": "slash", "disabled": false } + ] + }, + "bench": [ + { "slot": 2, "speciesId": "143", "fainted": false }, + { "slot": 3, "speciesId": "130", "fainted": false } + ] + }, + "opponent": { + "active": { + "speciesId": "483", + "nickname": "Dialga", + "levelActual": 72, + "levelEffective": 54, + "hpKnown": true, + "hp": 174, + "hpMax": 174, + "status": null + }, + "benchCount": 5 + } + }, + "pendingRequest": { + "kind": "choose_move_or_switch", + "deadlineMs": 45000 + } + } +} +``` + +### 공개 원칙 + +- 상대 bench는 `benchCount`만 공개한다. +- 상대 active의 기술 목록은 공개하지 않는다. +- 상대의 실제/유효 레벨 공개 여부는 ruleset 정책에 따르되, 초기 설계에서는 visible 상태로 둔다. + +--- + +## 2. `battle.request_action` + +서버가 일반 턴 선택을 요구할 때 보낸다. + +```json +{ + "type": "battle.request_action", + "roomId": "room_01JZ6Y4P4R", + "battleId": "battle_01JZ6Y8MK2", + "seq": 8, + "sentAt": "2026-04-11T07:15:40.000Z", + "payload": { + "turn": 4, + "phase": "awaiting_actions", + "requestId": "req_turn4_host", + "deadlineMs": 45000, + "request": { + "kind": "choose_move_or_switch", + "activePokemon": { + "slot": 1, + "speciesId": "006", + "hp": 121, + "hpMax": 158, + "status": null + }, + "availableMoves": [ + { "slot": 1, "id": "flamethrower", "disabled": false }, + { "slot": 2, "id": "slash", "disabled": false } + ], + "availableSwitches": [ + { "slot": 3, "speciesId": "143", "fainted": false } + ] + } + } +} +``` + +### 규칙 + +- 같은 턴이라도 양 플레이어의 request payload는 다를 수 있다. +- `availableSwitches`는 실제 가능한 교체 후보만 포함한다. +- 서버는 request 발송 시점부터 timeout을 계산한다. + +--- + +## 3. `battle.command_accepted` + +서버가 명령을 수락했음을 알린다. + +```json +{ + "type": "battle.command_accepted", + "roomId": "room_01JZ6Y4P4R", + "battleId": "battle_01JZ6Y8MK2", + "seq": 9, + "sentAt": "2026-04-11T07:15:43.000Z", + "payload": { + "clientCommandId": "cmd_01JZ6Y8W6M", + "turn": 4, + "phase": "awaiting_actions", + "lockedIn": true + } +} +``` + +### 목적 + +- 사용자가 이미 입력을 끝냈다는 것을 UX에 반영한다. +- 중복 클릭 / 중복 전송을 시각적으로 막는다. + +--- + +## 4. `battle.command_rejected` + +명령이 현재 상태와 맞지 않을 때 보낸다. + +```json +{ + "type": "battle.command_rejected", + "roomId": "room_01JZ6Y4P4R", + "battleId": "battle_01JZ6Y8MK2", + "seq": 10, + "sentAt": "2026-04-11T07:15:43.200Z", + "payload": { + "clientCommandId": "cmd_01JZ6Y8W6M", + "code": "PVP_COMMAND_PHASE_MISMATCH", + "message": "This command is not valid for the current phase.", + "retryable": true + } +} +``` + +### 대표 거부 코드 + +- `PVP_COMMAND_PHASE_MISMATCH` +- `PVP_COMMAND_TURN_MISMATCH` +- `PVP_COMMAND_DUPLICATE` +- `PVP_COMMAND_MOVE_INVALID` +- `PVP_COMMAND_SWITCH_INVALID` +- `PVP_COMMAND_REPLACEMENT_INVALID` +- `PVP_COMMAND_TIMEOUT` + +--- + +## 5. `battle.turn_resolved` + +서버가 해당 턴 결과를 공개 가능한 이벤트 묶음으로 내려준다. + +```json +{ + "type": "battle.turn_resolved", + "roomId": "room_01JZ6Y4P4R", + "battleId": "battle_01JZ6Y8MK2", + "seq": 12, + "sentAt": "2026-04-11T07:15:48.000Z", + "payload": { + "turn": 4, + "events": [ + { + "eventType": "move_used", + "actor": "self", + "speciesId": "006", + "moveId": "flamethrower" + }, + { + "eventType": "damage_applied", + "target": "opponent_active", + "hp": 91, + "hpMax": 174 + }, + { + "eventType": "status_applied", + "target": "opponent_active", + "status": "burn" + } + ], + "postTurnVisibleState": { + "self": { + "active": { "slot": 1, "speciesId": "006", "hp": 121, "hpMax": 158, "status": null }, + "bench": [ + { "slot": 3, "speciesId": "143", "fainted": false } + ] + }, + "opponent": { + "active": { "speciesId": "483", "hp": 91, "hpMax": 174, "status": "burn" }, + "benchCount": 5 + } + }, + "nextPhase": "awaiting_actions" + } +} +``` + +### 설계 포인트 + +- `events`는 연출/로그용이다. +- 클라이언트는 `events`를 재생하되, 최종 상태는 항상 `postTurnVisibleState`를 authoritative하게 본다. +- 상대에게 보이면 안 되는 정보는 `events`에서도 누출하지 않는다. + +--- + +## 6. `battle.force_replacement` + +기절한 플레이어에게 다음 포켓몬 선택을 요구한다. + +```json +{ + "type": "battle.force_replacement", + "roomId": "room_01JZ6Y4P4R", + "battleId": "battle_01JZ6Y8MK2", + "seq": 13, + "sentAt": "2026-04-11T07:15:48.300Z", + "payload": { + "turn": 4, + "phase": "awaiting_replacement", + "requestId": "req_replace_turn4_guest", + "deadlineMs": 45000, + "faintedSlot": 1, + "availableReplacements": [ + { "slot": 2, "speciesId": "445", "fainted": false }, + { "slot": 6, "speciesId": "248", "fainted": false } + ] + } +} +``` + +### 규칙 + +- 이 이벤트는 필요한 플레이어에게만 전송된다. +- 반대편 플레이어에게는 “상대가 다음 포켓몬을 선택 중” 정도의 대기 상태만 보여주면 된다. + +--- + +## 7. `battle.ended` + +```json +{ + "type": "battle.ended", + "roomId": "room_01JZ6Y4P4R", + "battleId": "battle_01JZ6Y8MK2", + "seq": 27, + "sentAt": "2026-04-11T07:20:10.000Z", + "payload": { + "result": "win", + "reason": "all_opponent_pokemon_fainted", + "finalVisibleState": { + "self": { + "remainingCount": 2 + }, + "opponent": { + "remainingCount": 0 + } + } + } +} +``` + +### 종료 사유 예시 + +- `all_opponent_pokemon_fainted` +- `forfeit` +- `timeout_forfeit` +- `admin_cancelled` +- `connection_abandoned` + +--- + +## 타임아웃 정책 + +### 기본 원칙 + +- 일반 턴과 replacement phase 모두 timeout을 가진다. +- 친선전 초기 버전은 지나치게 가혹하지 않게, **실격보다는 서버 대체 행동**을 우선 검토할 수 있다. +- 다만 악용을 줄이기 위해 phase별 정책을 분리한다. + +### 추천 초기 정책 + +| phase | timeout 시 처리 | +|---|---| +| `awaiting_actions` | 기본 기술 자동 선택 시도, 불가하면 랜덤 유효 기술 | +| `awaiting_replacement` | 랜덤 유효 replacement 선택 | +| 연속 timeout 누적 | 누적 기준 초과 시 패배 처리 가능 | + +### 왜 바로 실격이 아닌가 + +- 인게임 감성에 더 맞는다. +- 친선전 UX가 덜 거칠다. +- 네트워크 불안정에 덜 취약하다. + +단, 반복 악용 방지를 위해 timeout count는 서버가 별도 추적해야 한다. + +--- + +## 재접속 계약 + +### 규칙 + +1. 클라이언트 재접속 시 서버는 즉시 최신 `room.snapshot`을 다시 보낸다. +2. 아직 내 요청 phase가 살아 있으면, snapshot 안에 `pendingRequest`를 포함한다. +3. 이미 제출한 명령이 있다면 `commandSubmitted: true` 같은 상태를 snapshot에 포함할 수 있다. +4. 재접속 직후 과거 이벤트를 전부 재생할 필요는 없고, 최신 공개 상태가 우선이다. + +### 최소 재접속 snapshot 예시 + +```json +{ + "type": "room.snapshot", + "roomId": "room_01JZ6Y4P4R", + "battleId": "battle_01JZ6Y8MK2", + "seq": 21, + "sentAt": "2026-04-11T07:18:00.000Z", + "payload": { + "roomStatus": "in_progress", + "battleStatus": "awaiting_actions", + "yourSeat": "guest", + "turn": 6, + "visibleState": { + "self": { + "active": { "slot": 2, "speciesId": "445", "hp": 77, "hpMax": 161, "status": null }, + "bench": [ + { "slot": 6, "speciesId": "248", "fainted": false } + ] + }, + "opponent": { + "active": { "speciesId": "006", "hp": 42, "hpMax": 158, "status": "burn" }, + "benchCount": 1 + } + }, + "pendingRequest": { + "kind": "choose_move_or_switch", + "deadlineMs": 17000, + "commandSubmitted": false + } + } +} +``` + +--- + +## 서버 저장 계약 + +최소한 아래는 저장 가능하거나 재구성 가능해야 한다. + +- battle summary (`battleId`, roomId, generation, rulesetKey) +- authoritative turn index / phase +- player command log (`clientCommandId`, accepted/rejected result) +- visible/public event stream +- private event stream 또는 private derivation source +- timeout counters +- final result / reason + +### 무결성 원칙 + +- 같은 `clientCommandId`는 같은 seat + turn + phase 내에서 한 번만 수락 가능 +- `turn_resolved` 이후 이전 turn 명령은 절대 수락 불가 +- `finished` 이후 모든 명령은 거부 또는 무시 + +--- + +## 클라이언트 구현 메모 + +### 전투 중 + +- 클라이언트는 로컬 전투 계산을 하지 않는다. +- `events`는 연출용으로 사용하되, 최종 상태는 `postTurnVisibleState`를 신뢰한다. +- 명령 제출 후에는 입력 UI를 잠그고 `battle.command_accepted`를 기다린다. + +### 교체 phase + +- 일반 턴과 다른 화면/프롬프트로 다루는 편이 안전하다. +- 선택 가능한 교체 후보만 보여준다. + +### 재접속 시 + +- “다시 연결됨, 현재 턴 상태 동기화 완료” 같은 메시지를 보여준다. +- 끊기기 전 로컬 애니메이션 상태는 버리고 snapshot 기준으로 다시 그린다. + +--- + +## Phase 3 결론 + +초기 PvP의 실시간성은 “액션 게임식 프레임 동기화”가 아니라, **턴 요청과 authoritative 이벤트 스트림을 주고받는 실시간 세션**으로 구현하는 것이 맞다. +서버는 명령 수집과 결과 계산을 전부 책임지고, 클라이언트는 오직 현재 자신에게 공개된 상태와 입력 가능 행동만 처리해야 한다. + +--- + +## 다음 문서 + +- [실시간 배틀 흐름](../battle-flow.md) +- [치트 대응 정책](../../security/anti-cheat.md) +- [서버 아키텍처](../architecture.md) diff --git a/docs/pvp/server/contracts/room-and-match.md b/docs/pvp/server/contracts/room-and-match.md new file mode 100644 index 00000000..1cc02171 --- /dev/null +++ b/docs/pvp/server/contracts/room-and-match.md @@ -0,0 +1,488 @@ +# 친구전 룸 / 매치 성립 상세 계약 + +상위 문서: [PvP 서버 Contracts 문서](./README.md) +관련 문서: [API 계약 초안](../api-contract.md), [온라인 파티 등록 상세 계약](./party-registration.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session.md), [실시간 배틀 흐름](../battle-flow.md), [치트 대응 정책](../../security/anti-cheat.md) + +## 목적 + +이 문서는 초기 PvP의 **Phase 2 계약**을 상세화한다. +범위는 다음 세 가지다. + +1. `POST /api/pvp/rooms` +2. `POST /api/pvp/rooms/{roomId}/join` +3. `GET /api/pvp/rooms/{roomId}` + +즉, “두 플레이어가 어떤 generation / ruleset / party snapshot으로 실제 대전을 시작하게 되는가”를 서버 기준으로 확정하는 계약이다. + +--- + +## 범위 밖 + +이 문서는 아직 아래를 다루지 않는다. + +- WebSocket 세션 수립 세부 핸드셰이크 +- 턴별 명령 payload 상세 +- 대미지 계산 / 판정 로직 +- 래더 / 매치메이킹 큐 +- spectator / replay export + +--- + +## 계약 원칙 + +### 1. 룸은 배틀 전 staging area다 +룸은 단순 채팅방이 아니라, **배틀 시작 전에 generation / ruleset / party snapshot / player binding을 고정하는 준비 공간**이다. + +### 2. 룸 생성 시점과 참가 시점 모두 서버가 재검증한다 +호스트가 룸을 만들 때도, 상대가 참가할 때도 서버는 활성 파티 snapshot과 ruleset 일치를 다시 확인한다. + +### 3. 매치 성립 후에는 파티 스냅샷이 배틀 단위로 고정된다 +활성 파티는 재등록될 수 있지만, **이미 성립한 룸/배틀이 참조하는 snapshot**은 바뀌지 않는다. + +### 4. 친선전 초기 버전에는 별도 ready 버튼 없이 곧바로 시작 준비로 넘어간다 +사용자 경험은 “친구 코드 입력 → 붙으면 바로 배틀 진입”에 가깝게 간다. +따라서 별도 `ready` API는 두지 않고, 서버가 참가 성공 시 **match readiness**를 계산한다. + +### 5. 상대 백라인 공개는 룸 단계에서도 금지한다 +룸 상태 조회는 상대 존재 여부, generation, ruleset, 연결 상태 정도만 보여주며, **상대 파티 상세나 엔트리 리스트는 공개하지 않는다.** + +--- + +## 공통 전송 규칙 + +- 프로토콜: HTTPS + JSON +- 인증: `Authorization: Bearer ` 가정 +- room 식별자는 서버 발급 `roomId`와 사용자 공유용 `roomCode`를 분리한다. +- `roomCode`는 짧고 사람이 입력 가능한 문자열이지만, 내부 참조는 항상 `roomId`를 기준으로 한다. +- 모든 성공 응답은 최소한 `roomId`, `status`, `generation`, `rulesetKey`를 포함한다. +- 룸 상태 응답은 **플레이어별 투영(view)** 이다. 같은 룸이라도 조회자에 따라 `you`, `opponent` 블록 내용이 일부 다를 수 있다. + +### 공통 에러 envelope + +```json +{ + "error": { + "code": "PVP_ROOM_RULESET_MISMATCH", + "message": "Active party ruleset does not match the room ruleset.", + "retryable": false, + "details": { + "roomId": "room_01JZ6Y4P4R", + "generation": "gen4" + } + } +} +``` + +--- + +## 룸 상태 머신 + +```text +waiting_for_opponent + -> awaiting_presence + -> starting + -> in_progress + -> finished + -> cancelled +``` + +### 상태 의미 + +| 상태 | 의미 | +|---|---| +| `waiting_for_opponent` | 호스트만 존재, 상대 미참가 | +| `awaiting_presence` | 양 플레이어 binding 완료, WebSocket 진입 대기 | +| `starting` | 서버가 battle seed / player order / visible snapshot 생성 중 | +| `in_progress` | 배틀 진행 중 | +| `finished` | 배틀 종료 | +| `cancelled` | 시작 전 만료/취소 | + +--- + +## 공유 객체 계약 + +## RoomSummary + +```json +{ + "roomId": "room_01JZ6Y4P4R", + "roomCode": "A7KQ2M", + "mode": "friendly_private", + "status": "waiting_for_opponent", + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "createdByUserId": "user_host", + "createdAt": "2026-04-11T07:10:00Z", + "expiresAt": "2026-04-11T07:25:00Z" +} +``` + +### 의미 + +- `mode`: 초기 버전은 `friendly_private` 고정이다. +- `expiresAt`: 상대가 끝내 참가하지 않을 경우 룸을 자동 정리하기 위한 TTL이다. + +--- + +## RoomPlayerBinding + +```json +{ + "seat": "host", + "userId": "user_host", + "partySnapshotId": "ops_gen4_000123", + "partySnapshotVersion": 3, + "presence": "offline", + "joinedAt": "2026-04-11T07:10:00Z", + "battleReady": false +} +``` + +### 필드 규칙 + +| 필드 | 설명 | +|---|---| +| `seat` | `host` 또는 `guest` | +| `partySnapshotId` | 등록 계약에서 확정된 활성 온라인 파티 스냅샷 ID | +| `partySnapshotVersion` | 스냅샷 버전. 재등록과 구분하기 위해 저장 | +| `presence` | `offline`, `connected`, `disconnected` | +| `battleReady` | 현재 룸 기준 배틀 시작 가능성 | + +--- + +## RoomView + +`GET /rooms/{roomId}`가 반환하는 조회자 기준 룸 상태다. + +```json +{ + "room": { + "roomId": "room_01JZ6Y4P4R", + "roomCode": "A7KQ2M", + "mode": "friendly_private", + "status": "awaiting_presence", + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "createdAt": "2026-04-11T07:10:00Z", + "expiresAt": null + }, + "you": { + "seat": "host", + "partySnapshotId": "ops_gen4_000123", + "partyValidationStatus": "accepted", + "presence": "connected", + "battleReady": true + }, + "opponent": { + "seat": "guest", + "presence": "offline", + "battleReady": true, + "displayName": "Trainer B" + }, + "match": { + "freezeStatus": "pending_presence", + "battleId": null, + "battleStartedAt": null + } +} +``` + +### 공개 원칙 + +- `opponent`에는 display name, presence, battleReady 정도만 내려간다. +- 상대의 `partySnapshotId`는 초기 버전에서는 **비공개**로 둘 수 있다. 디버깅 편의보다 정보 노출 최소화가 우선이다. +- `freezeStatus`는 서버가 battle start freeze를 끝냈는지 설명한다. + +--- + +## Endpoint 1. 룸 생성 + +## `POST /api/pvp/rooms` + +### 요청 + +```json +{ + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "visibility": "private_friend" +} +``` + +### 요청 규칙 + +| 필드 | 규칙 | +|---|---| +| `generation` | 필수. host의 활성 파티 snapshot generation과 일치해야 함 | +| `rulesetKey` | 선택적이지만, 보내면 현재 서버 active ruleset과 일치해야 함 | +| `visibility` | 초기 버전은 `private_friend`만 허용 | + +### 서버 검증 순서 + +1. 인증된 사용자 확인 +2. `generation` 지원 여부 확인 +3. 해당 generation의 현재 active ruleset 조회 +4. 요청 `rulesetKey`가 있으면 active ruleset과 일치하는지 확인 +5. host의 활성 온라인 파티 snapshot 존재 여부 확인 +6. snapshot의 `validationStatus == accepted` 확인 +7. 이미 진행 중인 친선 룸/배틀에 묶여 있는지 확인 +8. 새 `roomId`, `roomCode`, TTL 생성 +9. host seat binding 저장 + +### 성공 응답 + +```json +{ + "room": { + "roomId": "room_01JZ6Y4P4R", + "roomCode": "A7KQ2M", + "mode": "friendly_private", + "status": "waiting_for_opponent", + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "createdAt": "2026-04-11T07:10:00Z", + "expiresAt": "2026-04-11T07:25:00Z" + }, + "you": { + "seat": "host", + "partySnapshotId": "ops_gen4_000123", + "partyValidationStatus": "accepted", + "presence": "offline", + "battleReady": false + }, + "opponent": null, + "match": { + "freezeStatus": "waiting_for_opponent", + "battleId": null, + "battleStartedAt": null + } +} +``` + +### 대표 실패 코드 + +- `PVP_RULESET_NOT_FOUND` +- `PVP_PARTY_NOT_REGISTERED` +- `PVP_PARTY_NOT_ACTIVE` +- `PVP_RULESET_MISMATCH` +- `PVP_ROOM_ALREADY_BOUND` +- `PVP_ROOM_VISIBILITY_INVALID` + +--- + +## Endpoint 2. 룸 참가 + +## `POST /api/pvp/rooms/{roomId}/join` + +### 요청 + +```json +{ + "roomCode": "A7KQ2M", + "generation": "gen4" +} +``` + +### 요청 규칙 + +| 필드 | 규칙 | +|---|---| +| `roomCode` | 필수. `roomId`와 매칭되어야 함 | +| `generation` | 필수. 룸 generation과 일치해야 함 | + +### 서버 검증 순서 + +1. 인증된 사용자 확인 +2. `roomId` 존재 여부 확인 +3. 룸 상태가 `waiting_for_opponent`인지 확인 +4. `roomCode` 일치 여부 확인 +5. host와 guest가 동일 사용자 아닌지 확인 +6. guest의 active online party snapshot 존재 여부 확인 +7. guest snapshot generation / ruleset이 room과 일치하는지 확인 +8. snapshot `validationStatus == accepted` 확인 +9. guest binding 저장 +10. 룸 상태를 `awaiting_presence`로 전이 +11. battle freeze 준비 상태 계산 + +### 성공 응답 + +```json +{ + "room": { + "roomId": "room_01JZ6Y4P4R", + "roomCode": "A7KQ2M", + "mode": "friendly_private", + "status": "awaiting_presence", + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "createdAt": "2026-04-11T07:10:00Z", + "expiresAt": null + }, + "you": { + "seat": "guest", + "partySnapshotId": "ops_gen4_000222", + "partyValidationStatus": "accepted", + "presence": "offline", + "battleReady": true + }, + "opponent": { + "seat": "host", + "presence": "offline", + "battleReady": true, + "displayName": "Trainer A" + }, + "match": { + "freezeStatus": "pending_presence", + "battleId": null, + "battleStartedAt": null + } +} +``` + +### 대표 실패 코드 + +- `PVP_ROOM_NOT_FOUND` +- `PVP_ROOM_CODE_MISMATCH` +- `PVP_ROOM_ALREADY_FILLED` +- `PVP_ROOM_STATE_INVALID` +- `PVP_ROOM_SELF_JOIN_FORBIDDEN` +- `PVP_PARTY_NOT_REGISTERED` +- `PVP_ROOM_GENERATION_MISMATCH` +- `PVP_ROOM_RULESET_MISMATCH` +- `PVP_PARTY_VALIDATION_REJECTED` + +--- + +## Endpoint 3. 룸 상태 조회 + +## `GET /api/pvp/rooms/{roomId}` + +### 요청 + +Body 없음. + +### 서버 검증 순서 + +1. 인증된 사용자 확인 +2. `roomId` 존재 여부 확인 +3. 요청 사용자가 host/guest 중 하나인지 확인 +4. 룸 상태를 조회자 기준 projection으로 직렬화 + +### 성공 응답 + +응답 구조는 `RoomView`를 따른다. + +### 대표 실패 코드 + +- `PVP_ROOM_NOT_FOUND` +- `PVP_ROOM_ACCESS_DENIED` + +--- + +## Battle freeze 계약 + +룸이 `awaiting_presence`로 들어가면, 서버는 배틀 시작용 freeze를 아래 단위로 준비한다. + +1. host party snapshot ID / version +2. guest party snapshot ID / version +3. room 생성 시점의 ruleset key +4. ruleset 정책 JSON hash +5. battle seed 생성용 엔트로피 + +### 왜 freeze가 필요한가 + +- 참가 직후 사용자가 활성 파티를 재등록해도, 이미 성립한 매치에는 영향이 없어야 한다. +- generation ruleset이 그 사이 교체되더라도, **이미 성립한 룸은 자기 ruleset key 기준으로 끝까지 진행**해야 한다. + +### freeze 결과 객체 예시 + +```json +{ + "battleFreeze": { + "generation": "gen4", + "rulesetKey": "tkm-friendly-gen4-v1", + "rulesetHash": "sha256:aa17...", + "hostPartySnapshotId": "ops_gen4_000123", + "guestPartySnapshotId": "ops_gen4_000222", + "battleSeed": "bseed_01JZ6Y6A7A" + } +} +``` + +--- + +## Presence 계약 + +친선전 초기 버전은 “코드 입력 후 바로 싸움” 감성을 유지하되, 실제 시작은 **양측 WebSocket presence**가 잡힌 뒤에만 허용한다. + +### 규칙 + +- HTTP join 성공만으로 곧바로 `in_progress`가 되지는 않는다. +- host/guest 모두 실시간 세션에 연결되면 `awaiting_presence -> starting`으로 전이한다. +- 한쪽이 참가 직후 오래 연결하지 않으면 룸은 timeout 후 `cancelled`될 수 있다. + +### presence timeout 예시 정책 + +| 항목 | 값 | +|---|---| +| opponent join 후 presence 대기 | 60초 | +| host 재연결 grace | 30초 | +| guest 재연결 grace | 30초 | + +이 값은 정책화 가능한 상수이며, API 응답에는 필요 최소한만 노출한다. + +--- + +## 룸 저장 계약 + +초기 버전에서 서버는 최소한 아래를 저장해야 한다. + +- room summary +- room code hash 또는 원문(운영 정책에 따라) +- host/guest binding +- freeze metadata +- lifecycle timestamps (`createdAt`, `joinedAt`, `startedAt`, `finishedAt`, `cancelledAt`) +- cancel / finish reason + +### 무결성 원칙 + +- host seat는 생성 시 1회만 설정 가능 +- guest seat는 비어 있을 때 1회만 설정 가능 +- `in_progress` 이후에는 seat binding 변경 불가 +- `finished` / `cancelled` 룸은 재사용 불가 + +--- + +## 클라이언트 구현 메모 + +### 룸 생성 시 + +- 사용자는 먼저 generation을 고르고 룸을 만든다. +- 실패가 `PVP_PARTY_NOT_REGISTERED`면 곧바로 등록 화면으로 유도한다. +- 생성 성공 시 room code를 크게 보여준다. + +### 룸 참가 시 + +- room code 입력 후 참가한다. +- 참가 실패가 ruleset / generation mismatch면 현재 활성 파티와 룸 조건이 안 맞는다는 메시지를 보여준다. +- 참가 성공 후에는 “상대와 연결 중...” 상태로 전환한다. + +### 룸 조회 시 + +- 내 presence와 상대 presence를 분리 표시한다. +- 상대 파티 정보는 보여주지 않는다. + +--- + +## Phase 2 결론 + +초기 친선 PvP에서 룸은 단순 입장 절차가 아니라, **배틀 직전의 server-authoritative 고정 지점**이다. +등록된 활성 파티와 ruleset을 룸에서 한 번 더 맞추고, 여기서 battle freeze를 만들어야 이후 실시간 배틀이 흔들리지 않는다. + +--- + +## 다음 문서 + +- [실시간 배틀 세션 상세 계약](./realtime-battle-session.md) +- [실시간 배틀 흐름](../battle-flow.md) +- [치트 대응 정책](../../security/anti-cheat.md) diff --git a/docs/pvp/server/storage/README.md b/docs/pvp/server/storage/README.md new file mode 100644 index 00000000..ec49c02d --- /dev/null +++ b/docs/pvp/server/storage/README.md @@ -0,0 +1,22 @@ +# PvP 서버 Storage 문서 + +상위 문서: [서버 설계 인덱스](../README.md) + +이 섹션은 서버 권한 PvP를 뒷받침하는 **엔티티 구조 / 저장 책임 / DB 스키마**를 정리한다. +즉, “무엇을 어떤 단위로 저장하고, 어떤 제약으로 무결성을 유지할지”를 설명하는 하위 묶음이다. + +## 포함 문서 + +1. [데이터 모델](./data-model.md) +2. [DB 스키마 초안](./database-schema.md) + +## 읽는 순서 + +- 먼저 [데이터 모델](./data-model.md)로 엔티티 관계와 책임 경계를 본다. +- 그 다음 [DB 스키마 초안](./database-schema.md)으로 컬럼 / 제약 / 인덱스 수준까지 내린다. + +## 이 묶음이 답하는 질문 + +- 온라인 파티 스냅샷은 어떤 생명주기를 가지는가? +- 룸, 배틀, 턴, 명령, 이벤트는 어떻게 분리 저장되는가? +- anti-cheat 관점에서 어떤 로그와 무결성 필드가 필요한가? diff --git a/docs/pvp/server/storage/data-model.md b/docs/pvp/server/storage/data-model.md new file mode 100644 index 00000000..f714fe8a --- /dev/null +++ b/docs/pvp/server/storage/data-model.md @@ -0,0 +1,222 @@ +# PvP 서버 데이터 모델 + +상위 문서: [PvP 서버 Storage 문서](./README.md) +관련 문서: [서버 아키텍처](../architecture.md), [DB 스키마 초안](./database-schema.md), [API 계약](../api-contract.md), [친구전 룸 / 매치 성립 상세 계약](../contracts/room-and-match.md), [실시간 배틀 세션 상세 계약](../contracts/realtime-battle-session.md), [성장 및 파티 등록](../../game-design/progression-and-party-registration.md), [세대별 ruleset 설계](../../game-design/generation-rules.md) + +## 목표 + +데이터 모델의 목표는 다음 세 가지다. + +1. 세대별 ruleset을 안정적으로 저장한다. +2. 온라인 파티 등록과 배틀 상태를 분리한다. +3. 실시간 배틀을 복구 가능한 상태로 기록한다. + +## 이 문서와 스키마 문서의 역할 분리 + +- 이 문서는 **엔티티 관계와 책임 분리**를 설명한다. +- [DB 스키마 초안](./database-schema.md)은 이를 **컬럼 / 제약 / 인덱스 수준**으로 구체화한다. + +즉, 먼저 이 문서로 “무슨 테이블이 왜 필요한지”를 보고, 그 다음 스키마 문서로 “실제로 어떤 컬럼으로 만들지”를 보는 흐름이 좋다. + +## 핵심 엔티티 + +### 1. `generation_rulesets` + +세대별 온라인 룰 정의. + +예시 필드: + +- `id` +- `generation` +- `ruleset_key` (`tkm-friendly-gen4-v1` 등) +- `battle_type` +- `party_size` +- `team_preview_enabled` +- `species_dup_clause` +- `legendary_mythical_limit` +- `restricted_limit` +- `effective_level_cap` +- `level_compression_policy` +- `is_active` +- `created_at` + +### 2. `restricted_species` + +ruleset 단위 restricted 목록. + +예시 필드: + +- `id` +- `ruleset_id` +- `generation` +- `species_id` +- `reason` +- `created_at` + +### 3. `online_party_snapshots` + +플레이어의 세대별 활성 온라인 파티 스냅샷. + +예시 필드: + +- `id` +- `player_id` +- `generation` +- `ruleset_id` +- `is_active` +- `source_state_hash` +- `registered_at` +- `updated_at` + +권장 제약: + +- `(player_id, generation, is_active=true)` 유니크 + +### 4. `online_party_members` + +등록 파티에 포함된 각 포켓몬 상세 정보. + +예시 필드: + +- `id` +- `party_snapshot_id` +- `slot_index` +- `species_id` +- `nickname` +- `level_actual` +- `level_effective` +- `hp` +- `attack` +- `defense` +- `sp_attack` +- `sp_defense` +- `speed` +- `moves_json` +- `growth_proof_json` + +초기엔 구조 단순화를 위해 JSON 저장도 가능하지만, 장기적으로는 일부 정규화가 도움이 될 수 있다. + +### 5. `battle_rooms` + +실시간 PvP 룸 메타 정보. + +예시 필드: + +- `id` +- `room_code` +- `generation` +- `ruleset_id` +- `status` (`waiting_for_opponent`, `awaiting_presence`, `starting`, `in_progress`, `finished`, `cancelled`) +- `visibility` (`private_friend`) +- `created_by` +- `created_at` +- `started_at` +- `ended_at` +- `winner_player_id` + +### 6. `battle_room_players` + +각 룸에 참여한 플레이어와 사용 파티 연결. + +예시 필드: + +- `id` +- `room_id` +- `player_id` +- `side` (`p1`, `p2`) +- `party_snapshot_id` +- `connection_status` +- `joined_at` + +### 7. `battle_turns` + +실시간 턴 수집 상태와 phase를 명시적으로 관리하는 보조 엔티티. + +예시 필드: + +- `id` +- `room_id` +- `turn_number` +- `phase` +- `request_kind` +- `deadline_at` +- `status` +- `resolved_at` + +### 8. `battle_commands` + +클라이언트가 서버에 제출한 명령 기록. + +예시 필드: + +- `id` +- `room_id` +- `player_id` +- `turn_number` +- `command_type` (`choose_move`, `choose_switch`, `choose_replacement`, `forfeit`) +- `payload_json` +- `submitted_at` +- `accepted` + +### 9. `battle_events` + +서버가 계산한 결과 이벤트 로그. + +예시 필드: + +- `id` +- `room_id` +- `sequence` +- `turn_number` +- `event_type` +- `public_payload_json` +- `private_payload_json` +- `created_at` + +`public_payload_json`은 양쪽에 공유 가능한 이벤트용, `private_payload_json`은 한쪽 플레이어에게만 보이는 정보를 담는 용도로 나눌 수 있다. + +### 10. `player_online_state` (선택) + +현재 온라인 접속/복귀/대기 상태를 관리하는 보조 엔티티. + +## 중요한 모델링 원칙 + +### 로컬 파티와 온라인 파티를 분리한다 + +현재 로컬 `config.party`는 온라인 PvP용 canonical source가 아니다. 따라서 온라인은 반드시 `online_party_snapshots`를 기준으로 돌아가야 한다. + +### ruleset을 배틀과 함께 고정한다 + +배틀이 시작되면 그 시점의 ruleset을 룸에 고정해야 한다. 그래야 추후 restricted 변경이 있어도 과거 배틀 기록과 충돌하지 않는다. + +### 배틀 로그는 복구 가능해야 한다 + +최소한 `battle_commands`와 `battle_events`가 있으면 재접속 시 상태 재구성이 가능하다. + +### 턴 phase는 별도 엔티티로 드러내는 편이 구현이 안전하다 + +문서 레벨에서는 생략 가능하지만, 실제 서버 구현에서는 `battle_turns` 같은 중간 엔티티가 있으면 timeout, 재접속, replacement phase 처리가 훨씬 깔끔해진다. + +## 추천 관계도 + +```text +player + ├── online_party_snapshots + │ └── online_party_members + └── battle_room_players + └── battle_rooms + ├── battle_turns + │ └── battle_commands + └── battle_events + +generation_rulesets + └── restricted_species +``` + +## 다음 문서 + +- [DB 스키마 초안](./database-schema.md) +- [API 계약](../api-contract.md) +- [친구전 룸 / 매치 성립 상세 계약](../contracts/room-and-match.md) +- [실시간 배틀 세션 상세 계약](../contracts/realtime-battle-session.md) +- [실시간 배틀 흐름](../battle-flow.md) diff --git a/docs/pvp/server/storage/database-schema.md b/docs/pvp/server/storage/database-schema.md new file mode 100644 index 00000000..aceffce4 --- /dev/null +++ b/docs/pvp/server/storage/database-schema.md @@ -0,0 +1,488 @@ +# PvP 서버 DB 스키마 초안 + +상위 문서: [PvP 서버 Storage 문서](./README.md) +관련 문서: [서버 데이터 모델](./data-model.md), [서버 아키텍처](../architecture.md), [API 계약](../api-contract.md), [친구전 룸 / 매치 성립 상세 계약](../contracts/room-and-match.md), [실시간 배틀 세션 상세 계약](../contracts/realtime-battle-session.md), [치트 대응 정책](../../security/anti-cheat.md), [PvP 작업 분해 / TODO](../../implementation/todo-breakdown.md) + +## 목적 + +이 문서는 `data-model.md`의 high-level 엔티티를 **실제 구현 직전 수준의 DB 스키마 초안**으로 구체화한다. +즉, “어떤 테이블이 필요하다”에서 한 단계 더 들어가서 아래를 고정한다. + +1. 각 테이블의 책임 +2. 각 컬럼의 타입과 의미 +3. 핵심 유니크 제약 / 인덱스 / 관계 +4. 구현 순서와 트랜잭션 경계 + +--- + +## 스키마 전제 + +### DB 엔진 가정 + +초기 초안은 **PostgreSQL 계열 문법/타입**을 기준으로 쓴다. + +- PK: `uuid` +- 시각: `timestamptz` +- 자유 구조 payload: `jsonb` +- 작은 상태값: `text + CHECK` 또는 enum + +다른 DB를 쓰더라도 구조적 의도는 유지 가능하다. + +### 플레이어 테이블은 외부 시스템으로 둔다 + +현재 Tokénmon repo에는 온라인 계정 canonical table이 아직 없다. +따라서 이 문서에서는 `player_id`를 **인증 레이어가 보장하는 안정적인 opaque identifier** 로 취급한다. + +예: + +- `player_123` +- `user:discord:abc` +- 향후 auth service subject id + +즉, 이 문서의 FK 설명에서 `player_id`는 “외부 플레이어 엔티티를 참조한다”는 의미다. + +### 온라인 등록은 accepted snapshot만 저장한다 + +초기 버전에서는 `online_party_snapshots`에 **서버 검증을 통과한 스냅샷만** 저장하는 쪽을 권장한다. +거부된 등록 시도 로그는 운영 테이블로 분리하는 것이 낫다. + +### 온라인 배틀은 등록 시점의 정규화 결과를 사용한다 + +배틀 직전 로컬 저장을 다시 읽지 않는다. +배틀은 반드시 다음 두 가지를 기준으로 시작한다. + +- 서버가 승인한 `online_party_snapshots` +- 서버가 룸 생성/시작 시점에 고정한 `ruleset_snapshot_json` + +--- + +## 권장 테이블 의존 순서 + +```text +generation_rulesets + -> restricted_species + -> online_party_snapshots + -> online_party_members + -> battle_rooms + -> battle_room_players + -> battle_turns + -> battle_commands + -> battle_events +``` + +초기 구현 순서는 이 의존 순서를 거의 그대로 따라가는 것이 좋다. + +--- + +## 1. `generation_rulesets` + +세대별 온라인 친선 PvP 룰의 canonical source. + +| 컬럼 | 타입 | 제약 | 의미 | +|---|---|---|---| +| `id` | `uuid` | PK | ruleset row id | +| `generation` | `text` | not null | `gen1` ~ `gen9` | +| `ruleset_key` | `text` | not null unique | 예: `tkm-friendly-gen4-v1` | +| `battle_type` | `text` | not null | 초기값 `singles` | +| `party_size` | `smallint` | not null | 초기값 `6` | +| `team_preview_enabled` | `boolean` | not null | 초기값 `false` | +| `lead_selection_mode` | `text` | not null | 초기값 `slot1_auto` | +| `species_dup_clause` | `boolean` | not null | 동일 종 금지 여부 | +| `legendary_mythical_limit` | `smallint` | not null | 초기값 `2` | +| `restricted_limit` | `smallint` | not null | 초기값 `1` | +| `level_display_mode` | `text` | not null | 예: `show_actual` | +| `effective_level_cap` | `smallint` | not null | 초기값 `60` | +| `level_compression_policy` | `text` | not null | 예: `soft_cap_after_50` | +| `fainted_replacement_mode` | `text` | not null | 초기값 `manual_choose_next` | +| `timeout_policy` | `text` | not null | 예: `default_action_on_timeout` | +| `is_active` | `boolean` | not null | 세대별 현재 적용 여부 | +| `created_at` | `timestamptz` | not null | 생성 시각 | +| `updated_at` | `timestamptz` | not null | 수정 시각 | + +### 핵심 제약 / 인덱스 + +- `unique (ruleset_key)` +- `index on (generation, is_active)` +- 필요하다면 `partial unique index on (generation) where is_active = true` + +### 메모 + +세대별 현재 활성 ruleset은 1개만 두는 편이 운영이 단순하다. +단, 과거 배틀 재현을 위해 old ruleset row는 soft-delete 하지 말고 남겨두는 것을 권장한다. + +--- + +## 2. `restricted_species` + +특정 ruleset에서 restricted 취급하는 종 목록. + +| 컬럼 | 타입 | 제약 | 의미 | +|---|---|---|---| +| `id` | `uuid` | PK | row id | +| `ruleset_id` | `uuid` | FK -> `generation_rulesets.id` | 어느 ruleset 기준인지 | +| `generation` | `text` | not null | 조회 최적화용 중복 컬럼 | +| `species_id` | `text` | not null | 도감/종 식별자 | +| `classification_reason` | `text` | null | 왜 restricted 인지 메모 | +| `created_at` | `timestamptz` | not null | 생성 시각 | + +### 핵심 제약 / 인덱스 + +- `unique (ruleset_id, species_id)` +- `index on (generation, ruleset_id)` + +### 메모 + +`legendary`, `mythical`은 종 메타데이터에서 오고, `restricted`는 **온라인 밸런스 정책 레이어**에서 온다. +따라서 `restricted`는 별도 테이블로 두는 것이 맞다. + +--- + +## 3. `online_party_snapshots` + +플레이어가 세대별로 온라인용으로 등록한 파티 스냅샷. + +| 컬럼 | 타입 | 제약 | 의미 | +|---|---|---|---| +| `id` | `uuid` | PK | snapshot id | +| `player_id` | `text` | not null | 외부 auth subject | +| `generation` | `text` | not null | 어느 세대 파티인지 | +| `ruleset_id` | `uuid` | FK -> `generation_rulesets.id` | 검증 기준 ruleset | +| `is_active` | `boolean` | not null | 세대별 현재 활성 스냅샷 여부 | +| `registration_origin` | `text` | not null | 초기값 `local_story_import` | +| `source_state_hash` | `text` | not null | 로컬 state fingerprint | +| `source_config_hash` | `text` | not null | 로컬 config fingerprint | +| `source_profile_version` | `text` | null | 추후 저장 포맷 버전 추적 | +| `registered_client_version` | `text` | null | 어떤 빌드/클라이언트에서 등록했는지 | +| `cheat_check_status` | `text` | not null | 초기값 `passed` | +| `registered_at` | `timestamptz` | not null | 최초 등록 시각 | +| `updated_at` | `timestamptz` | not null | 마지막 갱신 시각 | +| `superseded_by_snapshot_id` | `uuid` | null | 새 스냅샷으로 교체된 경우 연결 | + +### 핵심 제약 / 인덱스 + +- `index on (player_id, generation)` +- `partial unique index on (player_id, generation) where is_active = true` +- `index on (ruleset_id)` + +### 설계 포인트 + +- 유저가 **여러 번 재등록**할 수 있으므로 snapshot history는 남긴다. +- 그러나 초기 UX는 **generation별 active slot 1개**만 허용한다. +- 서버는 배틀 매칭 시 active snapshot만 참조한다. + +--- + +## 4. `online_party_members` + +등록 스냅샷에 포함된 각 포켓몬의 정규화된 온라인 배틀 입력값. + +| 컬럼 | 타입 | 제약 | 의미 | +|---|---|---|---| +| `id` | `uuid` | PK | member row id | +| `party_snapshot_id` | `uuid` | FK -> `online_party_snapshots.id` | 어느 스냅샷 소속인지 | +| `slot_index` | `smallint` | not null | 1~6 | +| `source_local_ref` | `text` | not null | 로컬 원본 참조값(현재 구조상 species 기반 ref 가능) | +| `species_id` | `text` | not null | 종 id | +| `form_id` | `text` | null | 폼/형태 구분이 필요할 때 | +| `nickname` | `text` | null | 별명 | +| `level_actual` | `smallint` | not null | 실제 성장 레벨 | +| `level_effective` | `smallint` | not null | 압축 후 실제 배틀 계산 레벨 | +| `exp_value` | `integer` | null | 로컬 성장 검증용 보조 값 | +| `stat_hp` | `integer` | not null | 등록 시점 최대 HP | +| `stat_attack` | `integer` | not null | 공격 | +| `stat_defense` | `integer` | not null | 방어 | +| `stat_sp_attack` | `integer` | not null | 특수공격 | +| `stat_sp_defense` | `integer` | not null | 특수방어 | +| `stat_speed` | `integer` | not null | 스피드 | +| `move_1_id` | `text` | null | 기술 1 | +| `move_2_id` | `text` | null | 기술 2 | +| `move_3_id` | `text` | null | 기술 3 | +| `move_4_id` | `text` | null | 기술 4 | +| `growth_proof_json` | `jsonb` | not null | 서버가 검증에 사용한 성장/획득 근거 요약 | +| `registered_at` | `timestamptz` | not null | 등록 시각 | + +### 핵심 제약 / 인덱스 + +- `unique (party_snapshot_id, slot_index)` +- `index on (party_snapshot_id)` +- 등록 트랜잭션 내부 검증: snapshot 단위에서 `species_id` 중복 금지 + +### 설계 포인트 + +- 초기 버전에서는 move slot을 4개 고정 컬럼으로 두는 편이 가장 단순하다. +- `growth_proof_json`은 완전 무결성 증명이 아니라, **온라인 등록 당시 서버가 어떤 근거로 통과시켰는지 보존하는 감사 정보**다. +- 현재 온라인 친선 규칙에서는 배틀 시작 시 **풀 HP / 비휘발 상태 초기화**를 기본 가정으로 두는 편이 안전하다. 따라서 현재 체력, 독/화상 같은 전투 중 상태는 등록 스냅샷에 넣지 않는다. + +--- + +## 5. `battle_rooms` + +친구전 방과 배틀 메타데이터를 저장한다. + +| 컬럼 | 타입 | 제약 | 의미 | +|---|---|---|---| +| `id` | `uuid` | PK | room id | +| `room_code` | `text` | not null unique | 초대 코드 | +| `generation` | `text` | not null | 해당 룸의 세대 | +| `ruleset_id` | `uuid` | FK -> `generation_rulesets.id` | 시작 시점 ruleset row | +| `ruleset_key` | `text` | not null | 조회 편의 / 감사용 | +| `ruleset_snapshot_json` | `jsonb` | not null | 배틀 시점 ruleset freeze | +| `battle_type` | `text` | not null | 초기값 `singles` | +| `visibility` | `text` | not null | 초기값 `private_friend` | +| `status` | `text` | not null | `waiting_for_opponent`, `awaiting_presence`, `starting`, `in_progress`, `finished`, `cancelled` | +| `current_phase` | `text` | not null | `awaiting_presence`, `starting`, `awaiting_actions`, `resolving_turn`, `awaiting_replacement`, `finished` | +| `created_by_player_id` | `text` | not null | 룸 생성자 | +| `winner_player_id` | `text` | null | 승자 | +| `winning_side` | `text` | null | `p1`, `p2` | +| `end_reason` | `text` | null | `all_fainted`, `forfeit`, `disconnect`, `cancelled` | +| `current_turn_number` | `integer` | not null | 현재 턴 번호 | +| `rng_seed` | `text` | null | 서버 내부 deterministic replay용 | +| `created_at` | `timestamptz` | not null | 생성 시각 | +| `started_at` | `timestamptz` | null | 시작 시각 | +| `ended_at` | `timestamptz` | null | 종료 시각 | + +### 핵심 제약 / 인덱스 + +- `unique (room_code)` +- `index on (created_by_player_id, created_at desc)` +- `index on (status, generation)` + +### 설계 포인트 + +가장 중요한 컬럼은 `ruleset_snapshot_json`이다. +이 값을 저장해야, 나중에 restricted 목록이나 레벨 정책이 바뀌더라도 **과거 배틀은 당시 기준 그대로 재현**할 수 있다. + +--- + +## 6. `battle_room_players` + +각 룸에 어떤 플레이어가 어느 side로 참가했고, 어떤 파티 스냅샷을 썼는지 기록한다. + +| 컬럼 | 타입 | 제약 | 의미 | +|---|---|---|---| +| `id` | `uuid` | PK | row id | +| `room_id` | `uuid` | FK -> `battle_rooms.id` | 어느 룸인지 | +| `player_id` | `text` | not null | 참가 플레이어 | +| `side` | `text` | not null | `p1` 또는 `p2` | +| `party_snapshot_id` | `uuid` | FK -> `online_party_snapshots.id` | 사용한 파티 | +| `connection_status` | `text` | not null | `joined`, `connected`, `disconnected`, `reconnected`, `left` | +| `last_seen_at` | `timestamptz` | null | 마지막 heartbeat 시각 | +| `joined_at` | `timestamptz` | not null | 입장 시각 | +| `ready_at` | `timestamptz` | null | 배틀 시작 준비 완료 시각 | + +### 핵심 제약 / 인덱스 + +- `unique (room_id, side)` +- `unique (room_id, player_id)` +- `index on (player_id, joined_at desc)` + +### 설계 포인트 + +온라인 배틀 시작 이후에는 이 row가 **어떤 snapshot으로 싸웠는지**를 고정한다. +중간에 active party를 재등록해도 이미 시작된 배틀에는 영향을 주지 않는다. + +--- + +## 7. `battle_turns` + +실시간 턴 수집과 phase 전이를 명시적으로 관리하는 보조 테이블. + +| 컬럼 | 타입 | 제약 | 의미 | +|---|---|---|---| +| `id` | `uuid` | PK | turn phase id | +| `room_id` | `uuid` | FK -> `battle_rooms.id` | 어느 룸인지 | +| `turn_number` | `integer` | not null | 메인 턴 번호 | +| `phase` | `text` | not null | `main`, `replacement_p1`, `replacement_p2`, `replacement_both` | +| `request_kind` | `text` | not null | `choose_move_or_switch`, `choose_replacement` 등 | +| `deadline_at` | `timestamptz` | null | 행동 제출 마감 | +| `status` | `text` | not null | `collecting`, `locked`, `resolved`, `expired` | +| `resolved_at` | `timestamptz` | null | phase 종료 시각 | +| `created_at` | `timestamptz` | not null | 생성 시각 | + +### 핵심 제약 / 인덱스 + +- `unique (room_id, turn_number, phase)` +- `index on (room_id, status)` +- `index on (deadline_at)` + +### 왜 필요한가 + +문서 수준에서는 생략 가능하지만 구현 관점에서는 이 테이블이 있으면 아래가 쉬워진다. + +- 명령 중복 제출 방지 +- timeout 처리 +- replacement phase 분리 +- 재접속 시 현재 입력 대기 상태 복원 + +--- + +## 8. `battle_commands` + +클라이언트가 제출한 실제 명령 원본. append-only 성격을 권장한다. + +| 컬럼 | 타입 | 제약 | 의미 | +|---|---|---|---| +| `id` | `uuid` | PK | command id | +| `room_id` | `uuid` | FK -> `battle_rooms.id` | 어느 룸인지 | +| `turn_id` | `uuid` | FK -> `battle_turns.id` | 어느 phase 요청에 대한 응답인지 | +| `player_id` | `text` | not null | 제출 플레이어 | +| `side` | `text` | not null | `p1` / `p2` | +| `command_type` | `text` | not null | `choose_move`, `choose_switch`, `choose_replacement`, `forfeit` | +| `request_nonce` | `text` | not null | 서버가 요청마다 발급한 nonce | +| `payload_json` | `jsonb` | not null | 실제 선택 payload | +| `accepted` | `boolean` | not null | 수락 여부 | +| `rejection_reason` | `text` | null | 거절 사유 | +| `submitted_at` | `timestamptz` | not null | 제출 시각 | +| `processed_at` | `timestamptz` | null | 검증 완료 시각 | + +### 핵심 제약 / 인덱스 + +- `unique (turn_id, player_id)` +- `unique (request_nonce, player_id)` +- `index on (room_id, submitted_at)` + +### 설계 포인트 + +- `battle_commands`는 서버 계산의 입력 감사 로그다. +- accepted/rejected를 모두 남기면, UX 디버깅과 악용 분석이 쉬워진다. +- 클라이언트는 같은 턴에 여러 번 바꾸는 기능을 지원하지 않는 초기안이므로 `unique (turn_id, player_id)`가 자연스럽다. + +--- + +## 9. `battle_events` + +서버가 계산해 확정한 결과 이벤트 로그. + +| 컬럼 | 타입 | 제약 | 의미 | +|---|---|---|---| +| `id` | `uuid` | PK | event id | +| `room_id` | `uuid` | FK -> `battle_rooms.id` | 어느 룸인지 | +| `sequence` | `bigint` | not null | 룸 내 단조 증가 순번 | +| `turn_number` | `integer` | not null | 어느 턴 결과인지 | +| `phase` | `text` | not null | `main`, `replacement_*`, `end` | +| `event_type` | `text` | not null | `move_used`, `damage_applied`, `pokemon_fainted` 등 | +| `public_payload_json` | `jsonb` | null | 양측 공통 공개 payload | +| `p1_private_payload_json` | `jsonb` | null | p1 전용 추가 payload | +| `p2_private_payload_json` | `jsonb` | null | p2 전용 추가 payload | +| `server_payload_json` | `jsonb` | null | 디버깅/복구용 서버 내부 payload | +| `created_at` | `timestamptz` | not null | 생성 시각 | + +### 핵심 제약 / 인덱스 + +- `unique (room_id, sequence)` +- `index on (room_id, turn_number, sequence)` + +### 설계 포인트 + +- 초기 PvP는 **상대 백라인 비공개**가 매우 중요하므로, public/private projection 분리를 row 구조에 반영하는 것이 좋다. +- 배틀 결과 조작 방지의 핵심은 결국 `battle_events`가 서버에서만 append된다는 점이다. + +--- + +## 테이블별 트랜잭션 권장 경계 + +### A. 온라인 파티 재등록 + +한 트랜잭션에서 다음 순서로 처리한다. + +1. active snapshot 조회 + lock +2. ruleset 검증 +3. duplicate / restricted / level 정책 검증 +4. cheat check 통과 확인 +5. 새 `online_party_snapshots` insert +6. `online_party_members` 1~6 insert +7. 이전 active snapshot 비활성화 +8. 새 snapshot 활성화 commit + +### B. 룸 시작 + +1. 룸 생성 또는 참가 완료 +2. 양측 active snapshot 확정 +3. `battle_rooms.ruleset_snapshot_json` 채움 +4. `battle_room_players` 2명 row 고정 +5. 첫 `battle_turns` row 생성 + +### C. 턴 처리 + +1. `battle_commands` 수집 +2. 양측 명령 충족 또는 timeout +3. 서버 계산 +4. `battle_events` append +5. `battle_rooms.current_turn_number` / `current_phase` 갱신 +6. 다음 `battle_turns` 생성 또는 종료 + +--- + +## 구현 우선순위 기준으로 본 필수 컬럼 + +### Phase 1에서 꼭 필요한 것 + +- `generation_rulesets` +- `restricted_species` +- `online_party_snapshots` +- `online_party_members` +- `source_state_hash`, `source_config_hash`, `growth_proof_json` + +### Phase 2에서 꼭 필요한 것 + +- `battle_rooms` +- `battle_room_players` +- `ruleset_snapshot_json` + +### Phase 3에서 꼭 필요한 것 + +- `battle_turns` +- `battle_commands` +- `battle_events` + +--- + +## 일부 컬럼을 굳이 지금 넣지 않는 이유 + +### 로컬 저장 전문(raw save blob) + +초기 버전에는 과하다. +해시와 정규화된 성장 결과, 그리고 `growth_proof_json` 정도면 친선전 수준의 초기 보호에는 충분하다. + +### spectator / replay / ladder 전용 테이블 + +지금 넣으면 설계가 빨리 커진다. +친선 PvP v1 범위를 넘는 기능은 후속 migration으로 분리하는 편이 낫다. + +### multiple active party slots + +초기에는 generation별 active slot 1개가 운영/UX/검증 모두 가장 단순하다. +이후 확장 시 `slot_name` 또는 `slot_index` 개념을 `online_party_snapshots`에 추가하면 된다. + +--- + +## 스키마와 현재 문서의 대응 관계 + +- `data-model.md`는 **엔티티 개념도**다. +- 이 문서는 **구체 컬럼/제약 초안**이다. +- `api-contract.md`는 이 스키마를 바탕으로 request/response shape를 정의한다. +- `anti-cheat.md`는 여기서 `source_*_hash`, `growth_proof_json`, `ruleset_snapshot_json`, `battle_events` 같은 컬럼이 왜 필요한지 설명한다. + +--- + +## 추천 migration 순서 + +1. `generation_rulesets` +2. `restricted_species` +3. `online_party_snapshots` +4. `online_party_members` +5. `battle_rooms` +6. `battle_room_players` +7. `battle_turns` +8. `battle_commands` +9. `battle_events` + +이 순서면 **ruleset -> registration -> room -> battle** 흐름과 구현 단계가 일치한다. + +--- + +## 다음 문서 + +- [서버 데이터 모델](./data-model.md) +- [HTTP / WebSocket API 계약 초안](../api-contract.md) +- [PvP 작업 분해 / TODO](../../implementation/todo-breakdown.md) diff --git a/src/cli/pvp-live.ts b/src/cli/pvp-live.ts new file mode 100644 index 00000000..cbb1bb04 --- /dev/null +++ b/src/cli/pvp-live.ts @@ -0,0 +1,247 @@ +#!/usr/bin/env -S npx tsx +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { createPvpRoomHttpClient } from '../pvp/room-http-client.js'; +import { createPvpSessionBootstrap, type PvpSessionBootstrapResult } from '../pvp/session-bootstrap.js'; +import { + createPvpSessionTerminalCli, + type CreatePvpSessionTerminalCliOptions, + type PvpSessionTerminalCliInputSource, + type PvpSessionTerminalCliScreenOutput, + type PvpSessionTerminalCliState, +} from '../pvp/session-terminal-cli.js'; +import { + createPvpSessionTerminalRunner, + type PvpSessionTerminalRunnerSessionClientLike, +} from '../pvp/session-terminal-runner.js'; +import type { CreatePvpSessionTerminalControllerOptions } from '../pvp/session-terminal-controller.js'; +import type { PvpSessionClient } from '../pvp/session-client.js'; +import { createPvpSessionTerminalStdioAdapter } from '../pvp/session-terminal-stdio.js'; +import type { CreatePvpWebSocket, PvpWebSocketLike } from '../pvp/websocket-client.js'; + +export type PvpLiveSessionCliSessionClientLike = + PvpSessionTerminalRunnerSessionClientLike & Partial>; + +export type PvpLiveSessionCliAdapter = + PvpSessionTerminalCliInputSource + & PvpSessionTerminalCliScreenOutput + & { + setAbortHandler?(handler: (() => void) | null): void; + }; + +export interface StartPvpLiveSessionCliOptions< + TSessionClient extends PvpLiveSessionCliSessionClientLike = PvpSessionClient, +> extends Pick, + Pick { + bootstrap: PvpSessionBootstrapResult; + adapter: PvpLiveSessionCliAdapter; + disconnectCloseInfo?: { + code?: number; + reason?: string; + }; +} + +export interface PvpLiveSessionCliHandle< + TSessionClient extends PvpLiveSessionCliSessionClientLike = PvpSessionClient, +> { + readonly roomId: string; + readonly sessionClient: TSessionClient; + getState(): PvpSessionTerminalCliState; + stop(): Promise; +} + +interface ParsedArgs { + command: 'create' | 'join' | 'resume' | 'help'; + flags: Record; +} + +function printHelp(): void { + console.log(`Usage: pvp-live --server-url --auth-token [options] + +Commands: + create Create a new live PvP room and connect immediately. + join Join an existing live PvP room with a room code. + resume Resume an existing room session. + +Required flags: + --server-url Base HTTP(S) URL for the PvP server. + --auth-token Viewer auth token issued by the room/session server. + +Shared flags: + --generation Battle generation key (for example: gen1, gen2, gen3, gen4). + --ruleset-key Optional ruleset key. + --visibility Room visibility for create (default: private_friend). + --room-id Room identifier for join/resume. + --room-code Room code required for join. + --help Show this help output. +`); +} + +function parseArgs(argv: string[]): ParsedArgs { + const flags: Record = {}; + let command: ParsedArgs['command'] = 'help'; + + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value) { + continue; + } + + if (!value.startsWith('--') && command === 'help') { + if (value === 'create' || value === 'join' || value === 'resume') { + command = value; + continue; + } + } + + if (value === '--help') { + return { command: 'help', flags }; + } + + if (!value.startsWith('--')) { + continue; + } + + const flagName = value.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith('--')) { + throw new Error(`Missing value for --${flagName}`); + } + + flags[flagName] = next; + index += 1; + } + + return { command, flags }; +} + +function requireFlag(flags: Record, name: string): string { + const value = flags[name]?.trim(); + if (!value) { + throw new Error(`--${name} is required`); + } + + return value; +} + +function requireWebSocketConstructor(): CreatePvpWebSocket { + const WebSocketCtor = globalThis.WebSocket; + if (!WebSocketCtor) { + throw new Error('Global WebSocket is not available in this runtime.'); + } + + return (url: string) => new WebSocketCtor(url) as unknown as PvpWebSocketLike; +} + +export async function startPvpLiveSessionCli< + TSessionClient extends PvpLiveSessionCliSessionClientLike = PvpSessionClient, +>( + options: StartPvpLiveSessionCliOptions, +): Promise> { + const { bootstrap, adapter } = options; + const sessionClient = bootstrap.sessionClient; + const disconnectCloseInfo = options.disconnectCloseInfo ?? { + code: 1000, + reason: 'terminal_cli_stop', + }; + + const runner = createPvpSessionTerminalRunner({ + sessionClient, + now: options.now, + createClientCommandId: options.createClientCommandId, + }); + const cli = createPvpSessionTerminalCli({ + runner, + input: adapter, + output: adapter, + normalizeInputToken: options.normalizeInputToken, + bootstrap: { + connect: () => { + sessionClient.connect?.(); + }, + disconnect: () => { + sessionClient.disconnect?.(disconnectCloseInfo); + }, + }, + }); + + await cli.start(); + + return { + roomId: bootstrap.roomId, + sessionClient, + getState: () => cli.getState(), + stop: () => cli.stop(), + }; +} + +async function run(): Promise { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed.command === 'help') { + printHelp(); + return; + } + + const serverUrl = requireFlag(parsed.flags, 'server-url'); + const authToken = requireFlag(parsed.flags, 'auth-token'); + const roomClient = createPvpRoomHttpClient({ + serverUrl, + fetch: globalThis.fetch.bind(globalThis), + }); + const bootstrapper = createPvpSessionBootstrap({ + serverUrl, + roomClient, + createSocket: requireWebSocketConstructor(), + }); + + const bootstrap = parsed.command === 'create' + ? await bootstrapper.createRoomSession({ + authToken, + generation: requireFlag(parsed.flags, 'generation'), + rulesetKey: parsed.flags['ruleset-key'], + visibility: parsed.flags.visibility ?? 'private_friend', + }) + : parsed.command === 'join' + ? await bootstrapper.joinRoomSession({ + authToken, + generation: requireFlag(parsed.flags, 'generation'), + roomId: requireFlag(parsed.flags, 'room-id'), + roomCode: requireFlag(parsed.flags, 'room-code'), + }) + : await bootstrapper.resumeRoomSession({ + authToken, + roomId: requireFlag(parsed.flags, 'room-id'), + }); + + const adapter = createPvpSessionTerminalStdioAdapter({ + stdin: process.stdin as never, + stdout: process.stdout, + signalTarget: process, + }); + const liveCli = await startPvpLiveSessionCli({ + bootstrap, + adapter, + }); + + try { + await new Promise((resolvePromise) => { + adapter.setAbortHandler(() => { + void liveCli.stop().finally(resolvePromise); + }); + }); + } finally { + adapter.setAbortHandler(null); + await liveCli.stop(); + } +} + +const invokedPath = process.argv[1]; +const invokedUrl = invokedPath ? pathToFileURL(resolve(invokedPath)).href : null; +if (invokedUrl && import.meta.url === invokedUrl) { + void run().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[pvp-live] ${message}`); + process.exitCode = 1; + }); +} diff --git a/src/pvp/action-request-view.ts b/src/pvp/action-request-view.ts new file mode 100644 index 00000000..86a712c8 --- /dev/null +++ b/src/pvp/action-request-view.ts @@ -0,0 +1,314 @@ +import type { BattleCommand, VisibleBenchPokemon, VisibleMoveOption } from '../server/battle/index.js'; +import type { PvpSessionClientState } from './session-client.js'; +import type { PendingActionRequest, PendingReplacementRequest, PvpPendingRequest } from './session-store.js'; + +export interface PvpActionRequestMenuEntry { + key: string; + kind: 'move' | 'switch' | 'replacement' | 'forfeit'; + label: string; + detail: string | null; + inputToken: string; + enabled: boolean; + disabledReason: string | null; + command: BattleCommand; +} + +export interface PvpActionRequestMenuSection { + id: 'moves' | 'switches' | 'replacements' | 'system'; + title: string; + entries: PvpActionRequestMenuEntry[]; +} + +export interface PvpActionRequestView { + requestKind: PvpPendingRequest['kind']; + turn: number; + phase: PvpPendingRequest['phase']; + requestId: string | null; + title: string; + prompt: string; + statusLabel: string; + activePokemonLabel: string | null; + commandSubmitted: boolean; + locked: boolean; + canInteract: boolean; + sections: PvpActionRequestMenuSection[]; +} + +export interface CreatePvpActionRequestViewOptions { + canInteract?: boolean; + commandSubmitted?: boolean; + pendingCommand?: boolean; +} + +function formatPokemonName(pokemon: { speciesId: string; nickname?: string }): string { + return pokemon.nickname ? `${pokemon.nickname} (${pokemon.speciesId})` : pokemon.speciesId; +} + +function formatActivePokemonLabel(request: PendingActionRequest): string | null { + const activePokemon = request.activePokemon; + if (!activePokemon) { + return null; + } + + const parts = [ + formatPokemonName(activePokemon), + `Lv.${activePokemon.levelEffective} (actual ${activePokemon.levelActual})`, + `HP ${activePokemon.hp}/${activePokemon.hpMax}`, + ]; + + if (activePokemon.status) { + parts.push(`status: ${activePokemon.status}`); + } + + return parts.join(' · '); +} + +function formatMoveDetail(move: VisibleMoveOption): string | null { + if (typeof move.currentPp === 'number') { + return `PP ${move.currentPp}`; + } + + return null; +} + +function formatBenchDetail(slot: VisibleBenchPokemon): string { + return slot.fainted ? '기절' : `슬롯 ${slot.slot}`; +} + +function resolveDisabledReason(options: { canInteract: boolean; unavailableReason?: string | null; intrinsicDisabled?: string | null }): string | null { + if (!options.canInteract) { + return options.unavailableReason ?? '현재 선택 불가'; + } + + return options.intrinsicDisabled ?? null; +} + +function buildMoveEntries(request: PendingActionRequest, canInteract: boolean, unavailableReason: string | null): PvpActionRequestMenuEntry[] { + return [...(request.availableMoves ?? [])] + .sort((left, right) => left.slot - right.slot) + .map((move) => { + const intrinsicDisabled = move.disabled ? '사용할 수 없는 기술' : null; + const enabled = canInteract && intrinsicDisabled === null; + + return { + key: `move:${move.slot}`, + kind: 'move', + label: `${move.slot}. ${move.id}`, + detail: formatMoveDetail(move), + inputToken: String(move.slot), + enabled, + disabledReason: resolveDisabledReason({ canInteract, unavailableReason, intrinsicDisabled }), + command: { + type: 'choose_move', + moveSlot: move.slot, + }, + }; + }); +} + +function buildSwitchEntries( + request: PendingActionRequest, + canInteract: boolean, + unavailableReason: string | null, +): PvpActionRequestMenuEntry[] { + return [...(request.availableSwitches ?? [])] + .sort((left, right) => left.slot - right.slot) + .map((slot) => { + const intrinsicDisabled = slot.fainted ? '기절한 포켓몬은 교체할 수 없음' : null; + const enabled = canInteract && intrinsicDisabled === null; + + return { + key: `switch:${slot.slot}`, + kind: 'switch', + label: formatPokemonName(slot), + detail: formatBenchDetail(slot), + inputToken: `switch:${slot.slot}`, + enabled, + disabledReason: resolveDisabledReason({ canInteract, unavailableReason, intrinsicDisabled }), + command: { + type: 'choose_switch', + targetSlot: slot.slot, + }, + }; + }); +} + +function buildReplacementEntries( + request: PendingReplacementRequest, + canInteract: boolean, + unavailableReason: string | null, +): PvpActionRequestMenuEntry[] { + return [...(request.availableReplacements ?? [])] + .sort((left, right) => left.slot - right.slot) + .map((slot) => { + const intrinsicDisabled = slot.fainted ? '기절한 포켓몬은 교체할 수 없음' : null; + const enabled = canInteract && intrinsicDisabled === null; + + return { + key: `replacement:${slot.slot}`, + kind: 'replacement', + label: formatPokemonName(slot), + detail: formatBenchDetail(slot), + inputToken: `replace:${slot.slot}`, + enabled, + disabledReason: resolveDisabledReason({ canInteract, unavailableReason, intrinsicDisabled }), + command: { + type: 'choose_replacement', + targetSlot: slot.slot, + }, + }; + }); +} + +function buildSystemEntries(canInteract: boolean, unavailableReason: string | null): PvpActionRequestMenuEntry[] { + return [ + { + key: 'forfeit', + kind: 'forfeit', + label: '항복', + detail: '즉시 패배 처리', + inputToken: 'forfeit', + enabled: canInteract, + disabledReason: canInteract ? null : (unavailableReason ?? '현재 선택 불가'), + command: { + type: 'forfeit', + }, + }, + ]; +} + +function pushSection( + sections: PvpActionRequestMenuSection[], + id: PvpActionRequestMenuSection['id'], + title: string, + entries: PvpActionRequestMenuEntry[], +): void { + if (entries.length === 0) { + return; + } + + sections.push({ id, title, entries }); +} + +function resolveViewAvailability(options: Required): { + commandSubmitted: boolean; + locked: boolean; + canInteract: boolean; + statusLabel: string; + unavailableReason: string | null; +} { + const commandSubmitted = options.commandSubmitted; + const locked = commandSubmitted || options.pendingCommand; + const canInteract = options.canInteract && !locked; + + if (commandSubmitted) { + return { + commandSubmitted, + locked, + canInteract, + statusLabel: '이미 제출됨', + unavailableReason: '이미 제출됨', + }; + } + + if (options.pendingCommand) { + return { + commandSubmitted, + locked, + canInteract, + statusLabel: '서버 확인 대기 중', + unavailableReason: '서버 확인 대기 중', + }; + } + + if (canInteract) { + return { + commandSubmitted, + locked, + canInteract, + statusLabel: '선택 가능', + unavailableReason: null, + }; + } + + return { + commandSubmitted, + locked, + canInteract, + statusLabel: '현재 선택 불가', + unavailableReason: '현재 선택 불가', + }; +} + +export function createPvpActionRequestViewFromPendingRequest( + request: PvpPendingRequest | null, + options: CreatePvpActionRequestViewOptions = {}, +): PvpActionRequestView | null { + if (!request) { + return null; + } + + const availability = resolveViewAvailability({ + canInteract: options.canInteract ?? true, + commandSubmitted: options.commandSubmitted ?? request.commandSubmitted, + pendingCommand: options.pendingCommand ?? false, + }); + + const sections: PvpActionRequestMenuSection[] = []; + + if (request.kind === 'choose_move_or_switch') { + pushSection(sections, 'moves', '기술', buildMoveEntries(request, availability.canInteract, availability.unavailableReason)); + pushSection(sections, 'switches', '교체', buildSwitchEntries(request, availability.canInteract, availability.unavailableReason)); + pushSection(sections, 'system', '시스템', buildSystemEntries(availability.canInteract, availability.unavailableReason)); + + return { + requestKind: request.kind, + turn: request.turn, + phase: request.phase, + requestId: request.requestId, + title: `${formatPokemonName(request.activePokemon ?? { speciesId: 'UNKNOWN' })} 행동 선택`, + prompt: '기술을 쓰거나 교체할 포켓몬을 선택하세요.', + statusLabel: availability.statusLabel, + activePokemonLabel: formatActivePokemonLabel(request), + commandSubmitted: availability.commandSubmitted, + locked: availability.locked, + canInteract: availability.canInteract, + sections, + }; + } + + pushSection( + sections, + 'replacements', + '교체 후보', + buildReplacementEntries(request, availability.canInteract, availability.unavailableReason), + ); + pushSection(sections, 'system', '시스템', buildSystemEntries(availability.canInteract, availability.unavailableReason)); + + return { + requestKind: request.kind, + turn: request.turn, + phase: request.phase, + requestId: request.requestId, + title: '교체 포켓몬 선택', + prompt: request.faintedSlot === null + ? '교체 포켓몬을 선택하세요.' + : `${request.faintedSlot}번 슬롯이 기절했습니다. 교체 포켓몬을 선택하세요.`, + statusLabel: availability.statusLabel, + activePokemonLabel: null, + commandSubmitted: availability.commandSubmitted, + locked: availability.locked, + canInteract: availability.canInteract, + sections, + }; +} + +export function createPvpActionRequestView( + state: Pick, +): PvpActionRequestView | null { + return createPvpActionRequestViewFromPendingRequest(state.session.pendingRequest, { + canInteract: state.canSendCommand, + commandSubmitted: state.session.pendingRequest?.commandSubmitted ?? false, + pendingCommand: state.session.pendingCommand !== null, + }); +} diff --git a/src/pvp/client-protocol.ts b/src/pvp/client-protocol.ts new file mode 100644 index 00000000..1fbc9a36 --- /dev/null +++ b/src/pvp/client-protocol.ts @@ -0,0 +1,116 @@ +import type { BattleCommandEnvelope } from '../server/battle/index.js'; +import type { PvpWsErrorEnvelope, PvpWsOutboundEnvelope, PvpWsPongEnvelope } from '../server/ws/index.js'; +import { + applyPvpServerEvent, + createBattleCommandEnvelope, + createPvpSessionState, + type CreateBattleCommandEnvelopeOptions, + type PvpSessionState, +} from './session-store.js'; + +export type PvpClientInboundEnvelope = PvpWsOutboundEnvelope; +export type PvpClientOutboundEnvelope = BattleCommandEnvelope | PvpWsPongEnvelope; + +export interface PvpClientState { + session: PvpSessionState; + lastTransportError: PvpWsErrorEnvelope | null; + lastTransportMessageAt: string | null; + lastTransportMessageType: PvpClientInboundEnvelope['type'] | null; + lastPingAt: string | null; + lastPongSentAt: string | null; +} + +export interface ApplyPvpTransportEnvelopeOptions { + pongSentAt?: string; +} + +export interface AppliedPvpTransportEnvelope { + state: PvpClientState; + outbound: PvpClientOutboundEnvelope[]; +} + +export interface CreatedPvpClientCommand { + state: PvpClientState; + envelope: BattleCommandEnvelope; +} + +function cloneTransportError(envelope: PvpWsErrorEnvelope): PvpWsErrorEnvelope { + return { + ...envelope, + details: envelope.details ? structuredClone(envelope.details) : undefined, + }; +} + +export function createPvpClientState(): PvpClientState { + return { + session: createPvpSessionState(), + lastTransportError: null, + lastTransportMessageAt: null, + lastTransportMessageType: null, + lastPingAt: null, + lastPongSentAt: null, + }; +} + +export function applyPvpTransportEnvelope( + state: PvpClientState, + envelope: PvpClientInboundEnvelope, + options: ApplyPvpTransportEnvelopeOptions = {}, +): AppliedPvpTransportEnvelope { + const baseState: PvpClientState = { + ...state, + lastTransportMessageAt: envelope.sentAt, + lastTransportMessageType: envelope.type, + }; + + if (envelope.type === 'ws.ping') { + const pongSentAt = options.pongSentAt ?? envelope.sentAt; + return { + state: { + ...baseState, + lastPingAt: envelope.sentAt, + lastPongSentAt: pongSentAt, + }, + outbound: [ + { + type: 'ws.pong', + sentAt: pongSentAt, + }, + ], + }; + } + + if (envelope.type === 'ws.error') { + return { + state: { + ...baseState, + lastTransportError: cloneTransportError(envelope), + }, + outbound: [], + }; + } + + return { + state: { + ...baseState, + session: applyPvpServerEvent(state.session, envelope), + lastTransportError: null, + }, + outbound: [], + }; +} + +export function createPvpClientCommand( + state: PvpClientState, + options: CreateBattleCommandEnvelopeOptions, +): CreatedPvpClientCommand { + const created = createBattleCommandEnvelope(state.session, options); + + return { + state: { + ...state, + session: created.state, + }, + envelope: created.envelope, + }; +} diff --git a/src/pvp/command-status-view.ts b/src/pvp/command-status-view.ts new file mode 100644 index 00000000..e63f4421 --- /dev/null +++ b/src/pvp/command-status-view.ts @@ -0,0 +1,226 @@ +import type { BattleCommand, BattleCommandPhase, BattleCommandRejectedPayload } from '../server/battle/index.js'; +import type { PvpSessionClientState } from './session-client.js'; +import { isCommandLocked, type PendingCommandState, type PvpPendingRequest, type PvpSessionState } from './session-store.js'; + +export type PvpCommandSubmissionState = 'none' | 'created' | 'accepted' | 'rejected_permanent'; + +export interface PvpCommandRejectionSummary { + code: BattleCommandRejectedPayload['code']; + message: string; + retryable: boolean; + statusLabel: string; + summary: string; +} + +export interface PvpCommandStatusView { + source: 'session'; + turn: number | null; + phase: BattleCommandPhase | null; + requestKind: PvpPendingRequest['kind'] | null; + hasPendingRequest: boolean; + submissionState: PvpCommandSubmissionState; + commandSummary: string | null; + statusLabel: string; + detailLabel: string | null; + locked: boolean; + canInteract: boolean; + requestCommandSubmitted: boolean; + pendingCommandStatus: PendingCommandState['status'] | null; + pendingCommandLockedIn: boolean | null; + relationSummary: string; + rejection: PvpCommandRejectionSummary | null; +} + +export interface CreatePvpCommandStatusViewOptions { + canInteract?: boolean; +} + +function summarizeCommand(command: BattleCommand | null | undefined): string | null { + if (!command) { + return null; + } + + switch (command.type) { + case 'choose_move': + return `기술 ${command.moveSlot}번`; + case 'choose_switch': + return `교체 슬롯 ${command.targetSlot}`; + case 'choose_replacement': + return `replacement 슬롯 ${command.targetSlot}`; + case 'forfeit': + return '항복'; + } +} + +function resolveSubmissionState(session: PvpSessionState): PvpCommandSubmissionState { + const pendingStatus = session.pendingCommand?.status; + + if (pendingStatus === 'rejected_permanent') { + return 'rejected_permanent'; + } + + if (pendingStatus === 'accepted' || session.pendingRequest?.commandSubmitted === true) { + return 'accepted'; + } + + if (pendingStatus === 'created') { + return 'created'; + } + + return 'none'; +} + +function createRejectionSummary(rejection: BattleCommandRejectedPayload | null): PvpCommandRejectionSummary | null { + if (!rejection) { + return null; + } + + const statusLabel = rejection.retryable ? '재제출 가능' : '재제출 불가'; + + return { + code: rejection.code, + message: rejection.message, + retryable: rejection.retryable, + statusLabel, + summary: `${statusLabel} · ${rejection.code} · ${rejection.message}`, + }; +} + +function resolveStatusLabel(view: { + hasPendingRequest: boolean; + submissionState: PvpCommandSubmissionState; + requestCommandSubmitted: boolean; + rejection: PvpCommandRejectionSummary | null; + pendingCommandLockedIn: boolean | null; +}): string { + if (!view.hasPendingRequest) { + return '제출 대기 없음'; + } + + switch (view.submissionState) { + case 'created': + return '서버 접수 확인 대기'; + case 'accepted': + return view.requestCommandSubmitted && view.pendingCommandLockedIn === null ? '이미 제출됨' : '명령 접수 완료'; + case 'rejected_permanent': + return '명령 영구 거부'; + case 'none': + return view.rejection?.retryable ? '다시 제출 필요' : '명령 선택 가능'; + } +} + +function resolveDetailLabel(view: { + hasPendingRequest: boolean; + submissionState: PvpCommandSubmissionState; + rejection: PvpCommandRejectionSummary | null; + pendingCommandLockedIn: boolean | null; +}): string | null { + if (!view.hasPendingRequest) { + return '활성 요청 없음'; + } + + switch (view.submissionState) { + case 'created': + return 'battle.command_accepted 대기 중'; + case 'accepted': + return view.pendingCommandLockedIn === null ? '스냅샷 기준 제출 완료' : (view.pendingCommandLockedIn ? '상대 입력/턴 해석 대기' : '서버 lock-in 대기'); + case 'rejected_permanent': + return view.rejection?.summary ?? '재제출 없이 서버 진행을 기다려야 합니다.'; + case 'none': + return view.rejection?.summary ?? '아직 제출한 명령 없음'; + } +} + +function resolveRelationSummary(view: { + hasPendingRequest: boolean; + submissionState: PvpCommandSubmissionState; + requestCommandSubmitted: boolean; + pendingCommandStatus: PendingCommandState['status'] | null; + rejection: PvpCommandRejectionSummary | null; +}): string { + if (!view.hasPendingRequest) { + return '활성 요청이 없어 commandSubmitted / pendingCommand 관계를 추적하지 않습니다.'; + } + + switch (view.submissionState) { + case 'created': + return '로컬 pendingCommand는 created 상태이지만 서버의 battle.command_accepted를 아직 받지 않아 request.commandSubmitted=false 입니다.'; + case 'accepted': + return view.pendingCommandStatus === 'accepted' + ? '서버가 명령을 접수해 pendingCommand.status=accepted, request.commandSubmitted=true 로 일치합니다.' + : '서버 스냅샷 기준으로 request.commandSubmitted=true 이지만 로컬 pendingCommand 세부 정보는 없습니다.'; + case 'rejected_permanent': + return '마지막 제출이 영구 거부되어 pendingCommand.status=rejected_permanent 입니다. 현재 요청은 잠긴 상태로 간주합니다.'; + case 'none': + return view.rejection?.retryable + ? '이전 명령이 거부되어 pendingCommand는 비워졌고 request.commandSubmitted=false 로 되돌아갔습니다. 다시 제출할 수 있습니다.' + : '아직 제출된 명령이 없어 pendingCommand=null, request.commandSubmitted=false 상태입니다.'; + } +} + +export function createPvpCommandStatusViewFromSession( + session: PvpSessionState | null, + options: CreatePvpCommandStatusViewOptions = {}, +): PvpCommandStatusView | null { + if (!session) { + return null; + } + + const hasPendingRequest = session.pendingRequest !== null; + const submissionState = resolveSubmissionState(session); + const requestCommandSubmitted = session.pendingRequest?.commandSubmitted ?? false; + const rejection = createRejectionSummary(session.lastRejectedCommand); + const locked = isCommandLocked(session); + const defaultCanInteract = hasPendingRequest && !locked; + const canInteract = options.canInteract ?? defaultCanInteract; + const pendingCommandStatus = session.pendingCommand?.status ?? null; + const pendingCommandLockedIn = session.pendingCommand?.lockedIn ?? null; + const turn = session.pendingCommand?.turn ?? session.pendingRequest?.turn ?? session.turn; + const phase = session.pendingCommand?.phase ?? session.pendingRequest?.phase ?? null; + + return { + source: 'session', + turn, + phase, + requestKind: session.pendingRequest?.kind ?? null, + hasPendingRequest, + submissionState, + commandSummary: summarizeCommand(session.pendingCommand?.command), + statusLabel: resolveStatusLabel({ + hasPendingRequest, + submissionState, + requestCommandSubmitted, + rejection, + pendingCommandLockedIn, + }), + detailLabel: resolveDetailLabel({ + hasPendingRequest, + submissionState, + rejection, + pendingCommandLockedIn, + }), + locked, + canInteract, + requestCommandSubmitted, + pendingCommandStatus, + pendingCommandLockedIn, + relationSummary: resolveRelationSummary({ + hasPendingRequest, + submissionState, + requestCommandSubmitted, + pendingCommandStatus, + rejection, + }), + rejection, + }; +} + +export function createPvpCommandStatusView(state: PvpSessionClientState | null): PvpCommandStatusView | null { + if (!state) { + return null; + } + + return createPvpCommandStatusViewFromSession(state.session, { + canInteract: state.hasPendingRequest && state.canSendCommand && !isCommandLocked(state.session), + }); +} diff --git a/src/pvp/index.ts b/src/pvp/index.ts new file mode 100644 index 00000000..353b5390 --- /dev/null +++ b/src/pvp/index.ts @@ -0,0 +1,187 @@ +export { + applyPvpTransportEnvelope, + createPvpClientCommand, + createPvpClientState, + type ApplyPvpTransportEnvelopeOptions, + type AppliedPvpTransportEnvelope, + type CreatedPvpClientCommand, + type PvpClientInboundEnvelope, + type PvpClientOutboundEnvelope, + type PvpClientState, +} from './client-protocol.js'; +export { + PvpWebSocketClient, + createPvpWebSocketClient, + createPvpWebSocketUrl, + type CreatePvpWebSocket, + type CreatePvpWebSocketClientOptions, + type PvpWebSocketClientState, + type PvpWebSocketCloseEvent, + type PvpWebSocketCloseInfo, + type PvpWebSocketErrorEvent, + type PvpWebSocketLike, + type PvpWebSocketMessageEvent, + type PvpWebSocketStateListener, + type PvpWebSocketTransportStatus, + type SendBattleCommandResult, +} from './websocket-client.js'; +export { + PvpReconnectController, + createPvpReconnectController, + type CreatePvpReconnectControllerOptions, + type PvpReconnectBackoffContext, + type PvpReconnectClient, + type PvpReconnectControllerState, + type PvpReconnectControllerStateListener, + type PvpReconnectDelayStrategy, + type PvpReconnectScheduler, + type PvpReconnectTrigger, +} from './reconnect-controller.js'; +export { + PvpSessionClient, + createPvpSessionClient, + type CreatePvpSessionClientOptions, + type PvpSessionClientReconnectState, + type PvpSessionClientState, + type PvpSessionClientStateListener, + type SendPvpSessionBattleCommandResult, +} from './session-client.js'; +export { + PvpRoomHttpClientError, + createPvpRoomHttpClient, + type CreatePvpRoomHttpClientOptions, + type CreatePvpRoomInput, + type GetPvpRoomInput, + type JoinPvpRoomInput, + type PvpFetchLikeResponse, + type PvpRoomAuthInput, + type PvpRoomHttpClient, + type PvpRoomHttpErrorKind, + type PvpRoomHttpFetch, + type PvpRoomHttpOperation, + type PvpRoomHttpRequestInit, + type PvpRoomHttpResponseLike, +} from './room-http-client.js'; +export { + PvpSessionBootstrap, + PvpSessionBootstrapError, + createPvpSessionBootstrap, + type CreatePvpSessionBootstrapOptions, + type CreateRoomSessionInput, + type CreateSessionFromRoomViewInput, + type JoinRoomSessionInput, + type PvpSessionBootstrapResult, + type PvpSessionBootstrapSessionClientLike, + type ResumeRoomSessionInput, +} from './session-bootstrap.js'; +export { + createPvpActionRequestView, + createPvpActionRequestViewFromPendingRequest, + type CreatePvpActionRequestViewOptions, + type PvpActionRequestMenuEntry, + type PvpActionRequestMenuSection, + type PvpActionRequestView, +} from './action-request-view.js'; +export { + createPvpCommandStatusView, + createPvpCommandStatusViewFromSession, + type CreatePvpCommandStatusViewOptions, + type PvpCommandRejectionSummary, + type PvpCommandStatusView, + type PvpCommandSubmissionState, +} from './command-status-view.js'; +export { + createPvpTurnResultView, + createPvpTurnResultViewFromPayload, + type PvpHealAppliedEvent, + type PvpTurnResolvedPayloadLike, + type PvpTurnResultBenchEntry, + type PvpTurnResultEvent, + type PvpTurnResultLogEntry, + type PvpTurnResultSideSummary, + type PvpTurnResultSummary, + type PvpTurnResultView, +} from './turn-result-view.js'; +export { + createPvpSessionScreenView, + type PvpSessionScreenSessionSummary, + type PvpSessionScreenStatus, + type PvpSessionScreenTransportSummary, + type PvpSessionScreenView, +} from './session-screen-view.js'; +export { + renderPvpSessionClientScreen, + renderPvpSessionScreen, +} from './session-screen-renderer.js'; +export { + PvpSessionTerminalController, + createPvpSessionTerminalController, + createPvpSessionTerminalSnapshot, + resolvePvpSessionTerminalInputToken, + type CreatePvpSessionTerminalControllerOptions, + type PvpSessionTerminalClient, + type PvpSessionTerminalClientLike, + type PvpSessionTerminalControllerLike, + type PvpSessionTerminalInputEntry, + type PvpSessionTerminalInputTokenFailureResult, + type PvpSessionTerminalInputTokenResult, + type PvpSessionTerminalResolvedInputTokenResult, + type PvpSessionTerminalSnapshot, + type PvpSessionTerminalSubmitFailureResult, + type PvpSessionTerminalSubmitResult, + type PvpSessionTerminalSubmitSuccessResult, +} from './session-terminal-controller.js'; +export { + PvpSessionTerminalRunner, + createPvpSessionTerminalRunner, + type CreatePvpSessionTerminalRunnerOptions, + type PvpSessionTerminalRunnerCommand, + type PvpSessionTerminalRunnerLike, + type PvpSessionTerminalRunnerSessionClientLike, + type PvpSessionTerminalRunnerState, + type PvpSessionTerminalRunnerStateListener, +} from './session-terminal-runner.js'; +export { + PvpSessionTerminalCli, + createPvpSessionTerminalCli, + type CreatePvpSessionTerminalCliOptions, + type PvpSessionTerminalCliBootstrapHooks, + type PvpSessionTerminalCliInputListener, + type PvpSessionTerminalCliInputSource, + type PvpSessionTerminalCliMaybePromise, + type PvpSessionTerminalCliScreenOutput, + type PvpSessionTerminalCliState, +} from './session-terminal-cli.js'; +export { + PvpSessionTerminalStdioAdapter, + createPvpSessionTerminalStdioAdapter, + type CreatePvpSessionTerminalStdioAdapterOptions, + type PvpSessionTerminalStdioAbortEvent, + type PvpSessionTerminalStdioAbortReason, + type PvpSessionTerminalStdioAbortHandlerTarget, + type PvpSessionTerminalStdioInput, + type PvpSessionTerminalStdioOutput, + type PvpSessionTerminalStdioReadlineLike, + type PvpSessionTerminalStdioSignalTarget, +} from './session-terminal-stdio.js'; +export { + startPvpLiveSessionCli, + type PvpLiveSessionCliAdapter, + type PvpLiveSessionCliHandle, + type PvpLiveSessionCliSessionClientLike, + type StartPvpLiveSessionCliOptions, +} from './live-session-cli.js'; +export { + applyPvpServerEvent, + createBattleCommandEnvelope, + createPvpSessionState, + hasPendingAction, + isCommandLocked, + type CreateBattleCommandEnvelopeOptions, + type CreatedBattleCommandEnvelope, + type PendingActionRequest, + type PendingCommandState, + type PendingReplacementRequest, + type PvpPendingRequest, + type PvpSessionState, +} from './session-store.js'; diff --git a/src/pvp/live-session-cli.ts b/src/pvp/live-session-cli.ts new file mode 100644 index 00000000..a1fcf25e --- /dev/null +++ b/src/pvp/live-session-cli.ts @@ -0,0 +1,123 @@ +import { + createPvpSessionTerminalCli, + type PvpSessionTerminalCli, + type PvpSessionTerminalCliState, + type PvpSessionTerminalCliInputSource, + type PvpSessionTerminalCliScreenOutput, +} from './session-terminal-cli.js'; +import { + createPvpSessionTerminalRunner, + type CreatePvpSessionTerminalRunnerOptions, + type PvpSessionTerminalRunner, + type PvpSessionTerminalRunnerSessionClientLike, + type PvpSessionTerminalRunnerState, +} from './session-terminal-runner.js'; +import { createPvpSessionTerminalStdioAdapter, type CreatePvpSessionTerminalStdioAdapterOptions } from './session-terminal-stdio.js'; +import type { PvpSessionBootstrapResult } from './session-bootstrap.js'; + +export interface PvpLiveSessionCliAdapter extends PvpSessionTerminalCliInputSource, PvpSessionTerminalCliScreenOutput { + setAbortHandler?(handler: (() => void) | null): void; +} + +export interface PvpLiveSessionCliSessionClientLike extends PvpSessionTerminalRunnerSessionClientLike { + connect?(): unknown; + disconnect?(closeInfo?: { code?: number; reason?: string }): unknown; +} + +export interface StartPvpLiveSessionCliOptions< + TSessionClient extends PvpLiveSessionCliSessionClientLike = PvpLiveSessionCliSessionClientLike, +> extends Pick { + bootstrap: PvpSessionBootstrapResult; + adapter?: PvpLiveSessionCliAdapter; + adapterOptions?: CreatePvpSessionTerminalStdioAdapterOptions; +} + +export interface PvpLiveSessionCliHandle { + readonly cli: PvpSessionTerminalCli; + readonly runner: PvpSessionTerminalRunner; + getState(): PvpSessionTerminalCliState; + getRunnerState(): PvpSessionTerminalRunnerState; + stop(): Promise; +} + +class StartedPvpLiveSessionCli implements PvpLiveSessionCliHandle { + readonly cli: PvpSessionTerminalCli; + + readonly runner: PvpSessionTerminalRunner; + + private readonly adapter: PvpLiveSessionCliAdapter; + + private stopPromise: Promise | null = null; + + constructor(options: { + cli: PvpSessionTerminalCli; + runner: PvpSessionTerminalRunner; + adapter: PvpLiveSessionCliAdapter; + }) { + this.cli = options.cli; + this.runner = options.runner; + this.adapter = options.adapter; + } + + getState(): PvpSessionTerminalCliState { + return this.cli.getState(); + } + + getRunnerState(): PvpSessionTerminalRunnerState { + return this.runner.getState(); + } + + async stop(): Promise { + if (!this.stopPromise) { + this.adapter.setAbortHandler?.(null); + this.stopPromise = this.cli.stop(); + } + + return this.stopPromise; + } +} + +export async function startPvpLiveSessionCli< + TSessionClient extends PvpLiveSessionCliSessionClientLike = PvpLiveSessionCliSessionClientLike, +>(options: StartPvpLiveSessionCliOptions): Promise { + const { bootstrap } = options; + const adapter = options.adapter ?? createPvpSessionTerminalStdioAdapter(options.adapterOptions); + const runner = createPvpSessionTerminalRunner({ + sessionClient: bootstrap.sessionClient, + now: options.now, + createClientCommandId: options.createClientCommandId, + }); + const cli = createPvpSessionTerminalCli({ + runner, + input: adapter, + output: adapter, + bootstrap: { + connect: () => { + bootstrap.sessionClient.connect?.(); + }, + disconnect: () => { + bootstrap.sessionClient.disconnect?.({ + code: 1000, + reason: 'terminal_cli_stop', + }); + }, + }, + }); + const started = new StartedPvpLiveSessionCli({ + cli, + runner, + adapter, + }); + + adapter.setAbortHandler?.(() => { + void started.stop(); + }); + + try { + await cli.start(); + return started; + } catch (error) { + adapter.setAbortHandler?.(null); + throw error; + } +} diff --git a/src/pvp/reconnect-controller.ts b/src/pvp/reconnect-controller.ts new file mode 100644 index 00000000..9c65f585 --- /dev/null +++ b/src/pvp/reconnect-controller.ts @@ -0,0 +1,300 @@ +import type { + PvpWebSocketClientState, + PvpWebSocketStateListener, +} from './websocket-client.js'; + +export interface PvpReconnectClient { + getState(): PvpWebSocketClientState; + subscribe(listener: PvpWebSocketStateListener): () => void; + connect(): PvpWebSocketClientState; + reconnect(): PvpWebSocketClientState; + disconnect(closeInfo?: { code?: number; reason?: string }): PvpWebSocketClientState; +} + +export interface PvpReconnectScheduler { + setTimeout(callback: () => void, delayMs: number): unknown; + clearTimeout(handle: unknown): void; +} + +export interface PvpReconnectBackoffContext { + attempt: number; + client: PvpWebSocketClientState; + trigger: Exclude; +} + +export type PvpReconnectTrigger = 'transport_error' | 'transport_close' | null; + +export type PvpReconnectDelayStrategy = (context: PvpReconnectBackoffContext) => number; + +export interface CreatePvpReconnectControllerOptions { + client: PvpReconnectClient; + now?: () => Date; + scheduler?: PvpReconnectScheduler; + baseDelayMs?: number; + maxDelayMs?: number; + multiplier?: number; + computeDelayMs?: PvpReconnectDelayStrategy; +} + +export interface PvpReconnectControllerState { + client: PvpWebSocketClientState; + autoReconnectEnabled: boolean; + reconnectScheduled: boolean; + reconnectAttempt: number; + reconnectDelayMs: number | null; + nextReconnectAt: string | null; + lastTrigger: PvpReconnectTrigger; +} + +export type PvpReconnectControllerStateListener = (state: PvpReconnectControllerState) => void; + +const DEFAULT_BASE_DELAY_MS = 1_000; +const DEFAULT_MAX_DELAY_MS = 30_000; +const DEFAULT_MULTIPLIER = 2; + +function cloneState(state: PvpReconnectControllerState): PvpReconnectControllerState { + return { + ...state, + client: structuredClone(state.client), + }; +} + +function createDefaultScheduler(): PvpReconnectScheduler { + return { + setTimeout(callback, delayMs) { + return globalThis.setTimeout(callback, delayMs); + }, + clearTimeout(handle) { + globalThis.clearTimeout(handle as Parameters[0]); + }, + }; +} + +function clampDelayMs(delayMs: number): number { + if (!Number.isFinite(delayMs) || delayMs < 0) { + return 0; + } + + return Math.floor(delayMs); +} + +function createDelayStrategy(baseDelayMs: number, maxDelayMs: number, multiplier: number): PvpReconnectDelayStrategy { + return ({ attempt }) => { + const exponential = baseDelayMs * multiplier ** Math.max(0, attempt - 1); + return Math.min(maxDelayMs, exponential); + }; +} + +function createInitialState(client: PvpReconnectClient): PvpReconnectControllerState { + return { + client: client.getState(), + autoReconnectEnabled: false, + reconnectScheduled: false, + reconnectAttempt: 0, + reconnectDelayMs: null, + nextReconnectAt: null, + lastTrigger: null, + }; +} + +export class PvpReconnectController { + private readonly client: PvpReconnectClient; + + private readonly now: () => Date; + + private readonly scheduler: PvpReconnectScheduler; + + private readonly computeDelayMs: PvpReconnectDelayStrategy; + + private readonly listeners = new Set(); + + private readonly unsubscribeClient: () => void; + + private reconnectTimer: unknown | null = null; + + private state: PvpReconnectControllerState; + + constructor(options: CreatePvpReconnectControllerOptions) { + const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS; + const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS; + const multiplier = options.multiplier ?? DEFAULT_MULTIPLIER; + + this.client = options.client; + this.now = options.now ?? (() => new Date()); + this.scheduler = options.scheduler ?? createDefaultScheduler(); + this.computeDelayMs = options.computeDelayMs ?? createDelayStrategy(baseDelayMs, maxDelayMs, multiplier); + this.state = createInitialState(this.client); + this.unsubscribeClient = this.client.subscribe((clientState) => { + this.handleClientState(clientState); + }); + } + + getState(): PvpReconnectControllerState { + return cloneState(this.state); + } + + subscribe(listener: PvpReconnectControllerStateListener): () => void { + this.listeners.add(listener); + listener(this.getState()); + + return () => { + this.listeners.delete(listener); + }; + } + + connect(): PvpReconnectControllerState { + this.cancelReconnectSchedule(); + const clientState = this.client.connect(); + this.patchState({ + client: clientState, + autoReconnectEnabled: true, + reconnectScheduled: false, + reconnectAttempt: 0, + reconnectDelayMs: null, + nextReconnectAt: null, + lastTrigger: null, + }); + return this.getState(); + } + + disconnect(closeInfo: { code?: number; reason?: string } = {}): PvpReconnectControllerState { + this.cancelReconnectSchedule(); + const clientState = this.client.disconnect(closeInfo); + this.patchState({ + client: clientState, + autoReconnectEnabled: false, + reconnectScheduled: false, + reconnectAttempt: 0, + reconnectDelayMs: null, + nextReconnectAt: null, + lastTrigger: null, + }); + return this.getState(); + } + + dispose(): void { + this.cancelReconnectSchedule(); + this.unsubscribeClient(); + this.listeners.clear(); + } + + private handleClientState(clientState: PvpWebSocketClientState): void { + if (clientState.transportStatus === 'connected') { + this.cancelReconnectSchedule(); + this.patchState({ + client: clientState, + reconnectScheduled: false, + reconnectAttempt: 0, + reconnectDelayMs: null, + nextReconnectAt: null, + lastTrigger: null, + }); + return; + } + + if (clientState.transportStatus === 'connecting' || clientState.transportStatus === 'reconnecting') { + this.cancelReconnectSchedule(); + this.patchState({ + client: clientState, + reconnectScheduled: false, + reconnectDelayMs: null, + nextReconnectAt: null, + }); + return; + } + + this.patchState({ client: clientState }); + + if (!this.state.autoReconnectEnabled) { + this.cancelReconnectSchedule(); + return; + } + + if (clientState.transportStatus !== 'closed' && clientState.transportStatus !== 'error') { + return; + } + + if (this.reconnectTimer) { + return; + } + + this.scheduleReconnect(clientState); + } + + private scheduleReconnect(clientState: PvpWebSocketClientState): void { + const trigger = clientState.transportStatus === 'error' ? 'transport_error' : 'transport_close'; + const attempt = this.state.reconnectAttempt + 1; + const delayMs = clampDelayMs(this.computeDelayMs({ attempt, client: structuredClone(clientState), trigger })); + const nextReconnectAt = new Date(this.now().getTime() + delayMs).toISOString(); + + this.reconnectTimer = this.scheduler.setTimeout(() => { + this.reconnectTimer = null; + this.patchState({ + reconnectScheduled: false, + reconnectDelayMs: null, + nextReconnectAt: null, + }); + + if (!this.state.autoReconnectEnabled) { + return; + } + + const latestClientState = this.client.getState(); + if ( + latestClientState.transportStatus === 'connected' + || latestClientState.transportStatus === 'connecting' + || latestClientState.transportStatus === 'reconnecting' + ) { + this.patchState({ client: latestClientState }); + return; + } + + try { + const nextClientState = this.client.reconnect(); + this.patchState({ client: nextClientState }); + } catch { + this.patchState({ client: this.client.getState() }); + } + }, delayMs); + + this.patchState({ + client: clientState, + reconnectScheduled: true, + reconnectAttempt: attempt, + reconnectDelayMs: delayMs, + nextReconnectAt, + lastTrigger: trigger, + }); + } + + private cancelReconnectSchedule(): void { + if (!this.reconnectTimer) { + return; + } + + this.scheduler.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + private patchState(patch: Partial): void { + this.state = { + ...this.state, + ...patch, + client: patch.client ? structuredClone(patch.client) : this.state.client, + }; + this.emit(); + } + + private emit(): void { + const snapshot = this.getState(); + for (const listener of this.listeners) { + listener(snapshot); + } + } +} + +export function createPvpReconnectController( + options: CreatePvpReconnectControllerOptions, +): PvpReconnectController { + return new PvpReconnectController(options); +} diff --git a/src/pvp/room-http-client.ts b/src/pvp/room-http-client.ts new file mode 100644 index 00000000..61dab1dc --- /dev/null +++ b/src/pvp/room-http-client.ts @@ -0,0 +1,301 @@ +import type { ErrorEnvelope } from '../server/http/http-types.js'; +import type { RoomView } from '../server/projection/index.js'; + +export interface PvpRoomHttpRequestInit { + method?: string; + headers?: Record; + body?: string; +} + +export interface PvpRoomHttpResponseLike { + status: number; + json(): Promise; +} + +export type PvpFetchLikeResponse = PvpRoomHttpResponseLike; + +export type PvpRoomHttpFetch = ( + url: string, + init?: PvpRoomHttpRequestInit, +) => Promise; + +export type PvpRoomHttpOperation = 'create_room' | 'join_room' | 'get_room'; + +export type PvpRoomHttpErrorKind = 'network_error' | 'http_error' | 'invalid_response'; + +export interface PvpRoomAuthInput { + authToken: string; +} + +export interface CreatePvpRoomInput extends PvpRoomAuthInput { + generation: string; + visibility: string; + rulesetKey?: string; +} + +export interface JoinPvpRoomInput extends PvpRoomAuthInput { + roomId: string; + roomCode: string; + generation: string; +} + +export interface GetPvpRoomInput extends PvpRoomAuthInput { + roomId: string; +} + +export interface PvpRoomHttpClient { + createRoom(input: CreatePvpRoomInput): Promise; + joinRoom(input: JoinPvpRoomInput): Promise; + getRoom(input: GetPvpRoomInput): Promise; +} + +export interface CreatePvpRoomHttpClientOptions { + serverUrl: string; + fetch: PvpRoomHttpFetch; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isRoomView(value: unknown): value is RoomView { + if (!isRecord(value)) { + return false; + } + + const room = value.room; + const you = value.you; + const opponent = value.opponent; + const match = value.match; + + return isRecord(room) + && typeof room.roomId === 'string' + && typeof room.roomCode === 'string' + && typeof room.mode === 'string' + && typeof room.status === 'string' + && typeof room.generation === 'string' + && typeof room.rulesetKey === 'string' + && typeof room.createdAt === 'string' + && (typeof room.expiresAt === 'string' || room.expiresAt === null) + && isRecord(you) + && typeof you.seat === 'string' + && typeof you.partySnapshotId === 'string' + && typeof you.partyValidationStatus === 'string' + && typeof you.presence === 'string' + && typeof you.battleReady === 'boolean' + && (opponent === null || ( + isRecord(opponent) + && typeof opponent.seat === 'string' + && typeof opponent.presence === 'string' + && typeof opponent.battleReady === 'boolean' + && typeof opponent.displayName === 'string' + )) + && isRecord(match) + && typeof match.freezeStatus === 'string' + && (typeof match.battleId === 'string' || match.battleId === null) + && (typeof match.battleStartedAt === 'string' || match.battleStartedAt === null); +} + +function isErrorEnvelope(value: unknown): value is ErrorEnvelope { + return isRecord(value) + && isRecord(value.error) + && typeof value.error.code === 'string' + && typeof value.error.message === 'string' + && typeof value.error.retryable === 'boolean'; +} + +function joinUrl(serverUrl: string, pathname: string): string { + const url = new URL(serverUrl); + const basePath = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname; + url.pathname = `${basePath}${pathname}`; + url.search = ''; + url.hash = ''; + return url.toString(); +} + +async function safeReadJson(response: PvpRoomHttpResponseLike): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function cloneDetails(details: Record | undefined): Record | undefined { + return details ? structuredClone(details) : undefined; +} + +export class PvpRoomHttpClientError extends Error { + readonly kind: PvpRoomHttpErrorKind; + + readonly operation: PvpRoomHttpOperation; + + readonly status: number | null; + + readonly code: string; + + readonly retryable: boolean; + + readonly details?: Record; + + constructor(options: { + kind: PvpRoomHttpErrorKind; + operation: PvpRoomHttpOperation; + status: number | null; + code: string; + message: string; + retryable: boolean; + details?: Record; + cause?: unknown; + }) { + super(options.message, options.cause ? { cause: options.cause } : undefined); + this.name = 'PvpRoomHttpClientError'; + this.kind = options.kind; + this.operation = options.operation; + this.status = options.status; + this.code = options.code; + this.retryable = options.retryable; + this.details = cloneDetails(options.details); + } +} + +export class DefaultPvpRoomHttpClient implements PvpRoomHttpClient { + private readonly serverUrl: string; + + private readonly fetchImpl: PvpRoomHttpFetch; + + constructor(options: CreatePvpRoomHttpClientOptions) { + this.serverUrl = options.serverUrl; + this.fetchImpl = options.fetch; + } + + async createRoom(input: CreatePvpRoomInput): Promise { + return this.requestRoomView('create_room', '/api/pvp/rooms', input.authToken, { + method: 'POST', + body: { + generation: input.generation, + visibility: input.visibility, + rulesetKey: input.rulesetKey, + }, + }); + } + + async joinRoom(input: JoinPvpRoomInput): Promise { + return this.requestRoomView( + 'join_room', + `/api/pvp/rooms/${encodeURIComponent(input.roomId)}/join`, + input.authToken, + { + method: 'POST', + body: { + roomCode: input.roomCode, + generation: input.generation, + }, + }, + ); + } + + async getRoom(input: GetPvpRoomInput): Promise { + return this.requestRoomView( + 'get_room', + `/api/pvp/rooms/${encodeURIComponent(input.roomId)}`, + input.authToken, + { method: 'GET' }, + ); + } + + private async requestRoomView( + operation: PvpRoomHttpOperation, + pathname: string, + authToken: string, + options: { + method: 'GET' | 'POST'; + body?: Record; + }, + ): Promise { + const url = joinUrl(this.serverUrl, pathname); + const headers: Record = { + accept: 'application/json', + authorization: `Bearer ${authToken.trim()}`, + }; + const init: PvpRoomHttpRequestInit = { + method: options.method, + headers, + }; + + if (options.body) { + headers['content-type'] = 'application/json'; + init.body = JSON.stringify(options.body); + } + + let response: PvpRoomHttpResponseLike; + try { + response = await this.fetchImpl(url, init); + } catch (error) { + throw new PvpRoomHttpClientError({ + kind: 'network_error', + operation, + status: null, + code: 'PVP_ROOM_HTTP_NETWORK_ERROR', + message: 'The PvP room HTTP request failed before the server responded.', + retryable: true, + details: { + url, + method: options.method, + }, + cause: error, + }); + } + + const payload = await safeReadJson(response); + if (response.status < 200 || response.status >= 300) { + if (isErrorEnvelope(payload)) { + throw new PvpRoomHttpClientError({ + kind: 'http_error', + operation, + status: response.status, + code: payload.error.code, + message: payload.error.message, + retryable: payload.error.retryable, + details: payload.error.details, + }); + } + + throw new PvpRoomHttpClientError({ + kind: 'invalid_response', + operation, + status: response.status, + code: 'PVP_ROOM_HTTP_INVALID_ERROR_ENVELOPE', + message: 'The PvP room server returned an invalid error payload.', + retryable: response.status >= 500, + details: { + url, + method: options.method, + responseStatus: response.status, + }, + }); + } + + if (!isRoomView(payload)) { + throw new PvpRoomHttpClientError({ + kind: 'invalid_response', + operation, + status: response.status, + code: 'PVP_ROOM_HTTP_INVALID_RESPONSE', + message: 'The PvP room server returned a malformed room payload.', + retryable: false, + details: { + url, + method: options.method, + responseStatus: response.status, + }, + }); + } + + return structuredClone(payload); + } +} + +export function createPvpRoomHttpClient(options: CreatePvpRoomHttpClientOptions): PvpRoomHttpClient { + return new DefaultPvpRoomHttpClient(options); +} diff --git a/src/pvp/session-bootstrap.ts b/src/pvp/session-bootstrap.ts new file mode 100644 index 00000000..3f9d2ce0 --- /dev/null +++ b/src/pvp/session-bootstrap.ts @@ -0,0 +1,166 @@ +import type { RoomView } from '../server/projection/index.js'; +import { + createPvpSessionClient, + type CreatePvpSessionClientOptions, + type PvpSessionClient, +} from './session-client.js'; +import type { + CreatePvpRoomInput, + GetPvpRoomInput, + JoinPvpRoomInput, + PvpRoomAuthInput, + PvpRoomHttpClient, +} from './room-http-client.js'; + +export interface PvpSessionBootstrapSessionClientLike { + connect?(): unknown; +} + +export interface PvpSessionBootstrapResult< + TSessionClient extends PvpSessionBootstrapSessionClientLike = PvpSessionClient, +> { + roomView: RoomView; + roomId: string; + sessionClient: TSessionClient; +} + +export interface CreateSessionFromRoomViewInput extends PvpRoomAuthInput { + roomView: RoomView; + autoConnect?: boolean; +} + +export interface CreateRoomSessionInput extends CreatePvpRoomInput { + autoConnect?: boolean; +} + +export interface JoinRoomSessionInput extends JoinPvpRoomInput { + autoConnect?: boolean; +} + +export interface ResumeRoomSessionInput extends GetPvpRoomInput { + autoConnect?: boolean; +} + +export interface CreatePvpSessionBootstrapOptions< + TSessionClient extends PvpSessionBootstrapSessionClientLike = PvpSessionClient, +> extends Omit { + roomClient: PvpRoomHttpClient; + autoConnect?: boolean; + createSessionClient?: (options: CreatePvpSessionClientOptions) => TSessionClient; +} + +export class PvpSessionBootstrapError extends Error { + readonly code: string; + + readonly details?: Record; + + constructor(options: { + code: string; + message: string; + details?: Record; + cause?: unknown; + }) { + super(options.message, options.cause ? { cause: options.cause } : undefined); + this.name = 'PvpSessionBootstrapError'; + this.code = options.code; + this.details = options.details ? structuredClone(options.details) : undefined; + } +} + +function extractRoomId(roomView: RoomView): string { + const roomId = roomView.room.roomId?.trim(); + if (!roomId) { + throw new PvpSessionBootstrapError({ + code: 'PVP_SESSION_BOOTSTRAP_ROOM_ID_INVALID', + message: 'The room projection did not include a usable room id.', + }); + } + + return roomId; +} + +export class PvpSessionBootstrap< + TSessionClient extends PvpSessionBootstrapSessionClientLike = PvpSessionClient, +> { + private readonly roomClient: PvpRoomHttpClient; + + private readonly sessionClientOptions: Omit; + + private readonly defaultAutoConnect: boolean; + + private readonly createSessionClientImpl: (options: CreatePvpSessionClientOptions) => TSessionClient; + + constructor(options: CreatePvpSessionBootstrapOptions) { + this.roomClient = options.roomClient; + this.sessionClientOptions = { + serverUrl: options.serverUrl, + createSocket: options.createSocket, + now: options.now, + scheduler: options.scheduler, + baseDelayMs: options.baseDelayMs, + maxDelayMs: options.maxDelayMs, + multiplier: options.multiplier, + computeDelayMs: options.computeDelayMs, + }; + this.defaultAutoConnect = options.autoConnect ?? false; + this.createSessionClientImpl = options.createSessionClient + ?? ((sessionOptions) => createPvpSessionClient(sessionOptions) as unknown as TSessionClient); + } + + async createRoomSession(input: CreateRoomSessionInput): Promise> { + const { autoConnect, ...request } = input; + const roomView = await this.roomClient.createRoom(request); + return this.createSessionFromRoomView({ + authToken: input.authToken, + roomView, + autoConnect, + }); + } + + async joinRoomSession(input: JoinRoomSessionInput): Promise> { + const { autoConnect, ...request } = input; + const roomView = await this.roomClient.joinRoom(request); + return this.createSessionFromRoomView({ + authToken: input.authToken, + roomView, + autoConnect, + }); + } + + async resumeRoomSession(input: ResumeRoomSessionInput): Promise> { + const { autoConnect, ...request } = input; + const roomView = await this.roomClient.getRoom(request); + return this.createSessionFromRoomView({ + authToken: input.authToken, + roomView, + autoConnect, + }); + } + + createSessionFromRoomView(input: CreateSessionFromRoomViewInput): PvpSessionBootstrapResult { + const roomId = extractRoomId(input.roomView); + const sessionClient = this.createSessionClientImpl({ + ...this.sessionClientOptions, + roomId, + token: input.authToken, + }); + + if (input.autoConnect ?? this.defaultAutoConnect) { + sessionClient.connect?.(); + } + + return { + roomView: structuredClone(input.roomView), + roomId, + sessionClient, + }; + } +} + +export function createPvpSessionBootstrap< + TSessionClient extends PvpSessionBootstrapSessionClientLike = PvpSessionClient, +>( + options: CreatePvpSessionBootstrapOptions, +): PvpSessionBootstrap { + return new PvpSessionBootstrap(options); +} diff --git a/src/pvp/session-client.ts b/src/pvp/session-client.ts new file mode 100644 index 00000000..4f97b793 --- /dev/null +++ b/src/pvp/session-client.ts @@ -0,0 +1,184 @@ +import { + createPvpReconnectController, + type CreatePvpReconnectControllerOptions, + type PvpReconnectController, + type PvpReconnectControllerState, + type PvpReconnectTrigger, +} from './reconnect-controller.js'; +import { + createPvpWebSocketClient, + type CreatePvpWebSocketClientOptions, + type PvpWebSocketClient, + type PvpWebSocketTransportStatus, + type SendBattleCommandResult, +} from './websocket-client.js'; +import type { PvpClientState } from './client-protocol.js'; +import { + hasPendingAction, + isCommandLocked, + type CreateBattleCommandEnvelopeOptions, + type PvpPendingRequest, + type PvpSessionState, +} from './session-store.js'; + +export interface CreatePvpSessionClientOptions + extends CreatePvpWebSocketClientOptions, + Omit {} + +export interface PvpSessionClientReconnectState { + autoReconnectEnabled: boolean; + attempt: number; + scheduled: boolean; + delay: number | null; + nextReconnectAt: string | null; + lastTrigger: PvpReconnectTrigger; +} + +export interface PvpSessionClientState { + transportStatus: PvpWebSocketTransportStatus; + session: PvpSessionState; + protocol: PvpClientState; + reconnect: PvpSessionClientReconnectState; + canSendCommand: boolean; + hasPendingRequest: boolean; + activeRequestKind: PvpPendingRequest['kind'] | null; +} + +export interface SendPvpSessionBattleCommandResult extends Omit { + state: PvpSessionClientState; +} + +export type PvpSessionClientStateListener = (state: PvpSessionClientState) => void; + +function cloneState(state: PvpSessionClientState): PvpSessionClientState { + return structuredClone(state); +} + +function deriveSessionClientState(controllerState: PvpReconnectControllerState): PvpSessionClientState { + const protocol = structuredClone(controllerState.client.protocol); + const session = structuredClone(protocol.session); + const hasPendingRequest = session.pendingRequest !== null; + + return { + transportStatus: controllerState.client.transportStatus, + session, + protocol, + reconnect: { + autoReconnectEnabled: controllerState.autoReconnectEnabled, + attempt: controllerState.reconnectAttempt, + scheduled: controllerState.reconnectScheduled, + delay: controllerState.reconnectDelayMs, + nextReconnectAt: controllerState.nextReconnectAt, + lastTrigger: controllerState.lastTrigger, + }, + canSendCommand: controllerState.client.transportStatus === 'connected' + && hasPendingAction(session) + && !isCommandLocked(session), + hasPendingRequest, + activeRequestKind: session.pendingRequest?.kind ?? null, + }; +} + +export class PvpSessionClient { + private readonly websocketClient: PvpWebSocketClient; + + private readonly reconnectController: PvpReconnectController; + + private readonly listeners = new Set(); + + private readonly unsubscribeReconnect: () => void; + + private state: PvpSessionClientState; + + private disposed = false; + + constructor(options: CreatePvpSessionClientOptions) { + this.websocketClient = createPvpWebSocketClient(options); + this.reconnectController = createPvpReconnectController({ + client: this.websocketClient, + now: options.now, + scheduler: options.scheduler, + baseDelayMs: options.baseDelayMs, + maxDelayMs: options.maxDelayMs, + multiplier: options.multiplier, + computeDelayMs: options.computeDelayMs, + }); + this.state = deriveSessionClientState(this.reconnectController.getState()); + this.unsubscribeReconnect = this.reconnectController.subscribe((controllerState) => { + if (this.disposed) { + return; + } + + this.state = deriveSessionClientState(controllerState); + this.emit(); + }); + } + + getState(): PvpSessionClientState { + return cloneState(this.state); + } + + subscribe(listener: PvpSessionClientStateListener): () => void { + this.listeners.add(listener); + listener(this.getState()); + + return () => { + this.listeners.delete(listener); + }; + } + + connect(): PvpSessionClientState { + this.assertNotDisposed(); + this.reconnectController.connect(); + return this.getState(); + } + + disconnect(closeInfo: { code?: number; reason?: string } = {}): PvpSessionClientState { + this.assertNotDisposed(); + this.reconnectController.disconnect(closeInfo); + return this.getState(); + } + + dispose(): void { + if (this.disposed) { + return; + } + + this.reconnectController.disconnect({ + code: 1000, + reason: 'session_client_dispose', + }); + this.disposed = true; + this.unsubscribeReconnect(); + this.reconnectController.dispose(); + this.listeners.clear(); + } + + sendBattleCommand(options: CreateBattleCommandEnvelopeOptions): SendPvpSessionBattleCommandResult { + this.assertNotDisposed(); + const result = this.websocketClient.sendBattleCommand(options); + + return { + envelope: result.envelope, + serialized: result.serialized, + state: this.getState(), + }; + } + + private assertNotDisposed(): void { + if (this.disposed) { + throw new Error('PVP_SESSION_CLIENT_DISPOSED'); + } + } + + private emit(): void { + const snapshot = this.getState(); + for (const listener of this.listeners) { + listener(snapshot); + } + } +} + +export function createPvpSessionClient(options: CreatePvpSessionClientOptions): PvpSessionClient { + return new PvpSessionClient(options); +} diff --git a/src/pvp/session-screen-renderer.ts b/src/pvp/session-screen-renderer.ts new file mode 100644 index 00000000..cc729a66 --- /dev/null +++ b/src/pvp/session-screen-renderer.ts @@ -0,0 +1,249 @@ +import { + createPvpSessionScreenView, + type PvpSessionScreenView, +} from './session-screen-view.js'; +import type { PvpSessionClientState } from './session-client.js'; +import type { PvpTurnResolvedPayloadLike } from './turn-result-view.js'; +import type { + PvpActionRequestMenuEntry, + PvpActionRequestMenuSection, + PvpActionRequestView, +} from './action-request-view.js'; +import type { PvpCommandStatusView } from './command-status-view.js'; +import type { PvpTurnResultLogEntry, PvpTurnResultView } from './turn-result-view.js'; + +function renderHeader(view: PvpSessionScreenView | null): string[] { + if (!view) { + return [ + '=== PvP Session Screen ===', + '상태: 세션 없음', + ]; + } + + const lines = [ + '=== PvP Session Screen ===', + `상태: ${view.statusLabel}`, + ]; + + if (view.detailLabel) { + lines.push(`- 상세: ${view.detailLabel}`); + } + + return lines; +} + +function renderTransportSection(view: PvpSessionScreenView | null): string[] { + if (!view) { + return [ + '[transport]', + '- 상태: 세션 없음', + '- 상세: 세션 스냅샷이 아직 없습니다.', + ]; + } + + return [ + '[transport]', + `- 상태: ${view.transport.summaryLabel}`, + `- 상세: ${view.transport.detailLabel ?? '추가 상세 없음'}`, + `- 재접속: ${view.transport.reconnectLabel}`, + ]; +} + +function renderSessionSection(view: PvpSessionScreenView | null): string[] { + if (!view) { + return [ + '[session]', + '- 요약: 세션 스냅샷이 아직 없습니다.', + ]; + } + + return [ + '[session]', + `- 요약: ${view.session.summaryLabel}`, + `- 상세: ${view.session.detailLabel}`, + `- 마지막 이벤트: ${view.session.lastEventLabel}`, + ]; +} + +function renderActionRequestSection(view: PvpSessionScreenView | null): string[] { + if (!view) { + return [ + '[action-request]', + '- 대기 중인 행동 요청이 없습니다.', + ]; + } + + if (!view.actionRequest) { + return [ + '[action-request]', + `- ${view.emptyStateLabel ?? '대기 중인 행동 요청이 없습니다.'}`, + ]; + } + + return [ + '[action-request]', + ...renderActionRequest(view.actionRequest), + ]; +} + +function renderActionRequest(actionRequest: PvpActionRequestView): string[] { + const lines = [ + `- 제목: ${actionRequest.title}`, + `- 프롬프트: ${actionRequest.prompt}`, + `- 상태: ${actionRequest.statusLabel}`, + `- 입력 가능: ${actionRequest.canInteract ? '예' : '아니오'}`, + ]; + + if (actionRequest.activePokemonLabel) { + lines.push(`- 활성 포켓몬: ${actionRequest.activePokemonLabel}`); + } + + for (const section of actionRequest.sections) { + lines.push(...renderActionRequestSectionEntries(section)); + } + + return lines; +} + +function renderActionRequestSectionEntries(section: PvpActionRequestMenuSection): string[] { + const lines = [`- 섹션: ${section.title}`]; + + for (const entry of section.entries) { + lines.push(renderActionRequestEntry(entry)); + } + + return lines; +} + +function renderActionRequestEntry(entry: PvpActionRequestMenuEntry): string { + const parts = [ + ` * [${entry.enabled ? '가능' : '잠김'}] ${entry.label}`, + `token=${entry.inputToken}`, + ]; + + if (entry.detail) { + parts.push(entry.detail); + } + + if (!entry.enabled && entry.disabledReason) { + parts.push(`사유 ${entry.disabledReason}`); + } + + return parts.join(' | '); +} + +function renderCommandStatusSection(view: PvpSessionScreenView | null): string[] { + if (!view) { + return [ + '[command-status]', + '- 제출 상태를 표시할 세션이 없습니다.', + ]; + } + + if (!view.commandStatus) { + return [ + '[command-status]', + '- 제출 상태를 계산할 수 없습니다.', + ]; + } + + return [ + '[command-status]', + ...renderCommandStatus(view.commandStatus), + ]; +} + +function renderCommandStatus(commandStatus: PvpCommandStatusView): string[] { + const lines = [ + `- 상태: ${commandStatus.statusLabel}`, + `- 상세: ${commandStatus.detailLabel ?? '추가 상세 없음'}`, + `- 제출 상태: ${commandStatus.submissionState}`, + `- 상호작용: ${commandStatus.canInteract ? '가능' : '잠김'}`, + `- 관계 요약: ${commandStatus.relationSummary}`, + ]; + + if (commandStatus.commandSummary) { + lines.push(`- 명령: ${commandStatus.commandSummary}`); + } + + if (commandStatus.rejection) { + lines.push(`- 거부: ${commandStatus.rejection.summary}`); + } + + return lines; +} + +function renderTurnResultSection(view: PvpSessionScreenView | null): string[] { + if (!view) { + return [ + '[turn-result]', + '- 표시할 턴 결과가 없습니다.', + ]; + } + + if (!view.turnResult) { + return [ + '[turn-result]', + `- ${view.emptyStateLabel ?? '표시할 턴 결과가 없습니다.'}`, + ]; + } + + return [ + '[turn-result]', + ...renderTurnResult(view.turnResult), + ]; +} + +function renderTurnResult(turnResult: PvpTurnResultView): string[] { + const lines = [ + `- 제목: ${turnResult.title}`, + `- 상태: ${turnResult.summary.statusLabel}`, + `- 다음 단계: ${turnResult.summary.nextPhaseLabel}`, + `- 내 포켓몬: ${turnResult.summary.self.activeLabel ?? '공개된 활성 포켓몬 없음'}`, + `- 내 벤치: ${turnResult.summary.self.benchLabel}`, + `- 상대 포켓몬: ${turnResult.summary.opponent.activeLabel ?? '공개된 활성 포켓몬 없음'}`, + `- 상대 벤치: ${turnResult.summary.opponent.benchLabel}`, + ]; + + if (turnResult.summary.terminalResultLabel) { + lines.push(`- 종료: ${turnResult.summary.terminalResultLabel}`); + } + + if (turnResult.logs.length === 0) { + lines.push('- 로그: 표시할 턴 이벤트가 없습니다.'); + return lines; + } + + for (const log of turnResult.logs) { + lines.push(renderTurnResultLog(log)); + } + + return lines; +} + +function renderTurnResultLog(log: PvpTurnResultLogEntry): string { + return ` * ${log.title}: ${log.message}`; +} + +export function renderPvpSessionScreen(view: PvpSessionScreenView | null): string { + return [ + ...renderHeader(view), + '', + ...renderTransportSection(view), + '', + ...renderSessionSection(view), + '', + ...renderActionRequestSection(view), + '', + ...renderCommandStatusSection(view), + '', + ...renderTurnResultSection(view), + ].join('\n'); +} + +export function renderPvpSessionClientScreen( + state: PvpSessionClientState | null, + payloadOverride: PvpTurnResolvedPayloadLike | null = null, +): string { + return renderPvpSessionScreen(createPvpSessionScreenView(state, payloadOverride)); +} diff --git a/src/pvp/session-screen-view.ts b/src/pvp/session-screen-view.ts new file mode 100644 index 00000000..7de92064 --- /dev/null +++ b/src/pvp/session-screen-view.ts @@ -0,0 +1,247 @@ +import type { BattleSessionPhase, BattleServerEventEnvelope } from '../server/battle/index.js'; +import type { PvpSessionClientReconnectState, PvpSessionClientState } from './session-client.js'; +import { + createPvpActionRequestView, + type PvpActionRequestView, +} from './action-request-view.js'; +import { + createPvpCommandStatusView, + type PvpCommandStatusView, +} from './command-status-view.js'; +import { + createPvpTurnResultView, + type PvpTurnResolvedPayloadLike, + type PvpTurnResultView, +} from './turn-result-view.js'; + +export type PvpSessionScreenStatus = 'idle' | 'awaiting_input' | 'command_locked' | 'reconnecting' | 'transport_wait' | 'terminal'; + +export interface PvpSessionScreenTransportSummary { + transportStatus: PvpSessionClientState['transportStatus']; + summaryLabel: string; + detailLabel: string | null; + reconnectLabel: string; + live: boolean; + recovering: boolean; +} + +export interface PvpSessionScreenSessionSummary { + roomId: string | null; + battleId: string | null; + turn: number | null; + battleStatus: BattleSessionPhase | null; + roomStatus: PvpSessionClientState['session']['roomStatus']; + generation: PvpSessionClientState['session']['generation']; + rulesetKey: PvpSessionClientState['session']['rulesetKey']; + yourSeat: PvpSessionClientState['session']['yourSeat']; + summaryLabel: string; + detailLabel: string; + lastEventType: BattleServerEventEnvelope['type'] | null; + lastEventAt: string | null; + lastEventLabel: string; +} + +export interface PvpSessionScreenView { + source: 'session'; + status: PvpSessionScreenStatus; + statusLabel: string; + detailLabel: string | null; + emptyStateLabel: string | null; + terminal: boolean; + hasPendingRequest: boolean; + hasTurnResult: boolean; + hasRenderableContent: boolean; + transport: PvpSessionScreenTransportSummary; + session: PvpSessionScreenSessionSummary; + actionRequest: PvpActionRequestView | null; + commandStatus: PvpCommandStatusView | null; + turnResult: PvpTurnResultView | null; +} + +function createTransportSummary(state: PvpSessionClientState): PvpSessionScreenTransportSummary { + return { + transportStatus: state.transportStatus, + summaryLabel: summarizeTransportStatus(state.transportStatus), + detailLabel: summarizeReconnectDetail(state.reconnect), + reconnectLabel: state.reconnect.autoReconnectEnabled ? '자동 재접속 켜짐' : '자동 재접속 꺼짐', + live: state.transportStatus === 'connected', + recovering: state.transportStatus === 'reconnecting' || state.reconnect.scheduled, + }; +} + +function summarizeTransportStatus(status: PvpSessionClientState['transportStatus']): string { + switch (status) { + case 'idle': + return '연결 대기'; + case 'connecting': + return '서버 연결 중'; + case 'connected': + return '실시간 연결됨'; + case 'reconnecting': + return '재접속 시도 중'; + case 'closed': + return '연결 종료'; + case 'error': + return '연결 오류'; + } +} + +function summarizeReconnectDetail(reconnect: PvpSessionClientReconnectState): string { + if (reconnect.scheduled && reconnect.nextReconnectAt) { + return `${reconnect.attempt}회차 재접속 예약 · ${reconnect.nextReconnectAt}`; + } + + if (reconnect.scheduled && reconnect.delay !== null) { + return `${reconnect.attempt}회차 재접속 예약 · ${reconnect.delay}ms 후`; + } + + if (reconnect.autoReconnectEnabled) { + return '자동 재접속 대기 없음'; + } + + return '자동 재접속 꺼짐'; +} + +function createSessionSummary(state: PvpSessionClientState): PvpSessionScreenSessionSummary { + const session = state.session; + const summaryParts = [ + session.generation, + session.rulesetKey ? `룰 ${session.rulesetKey}` : null, + typeof session.turn === 'number' ? `${session.turn}턴` : null, + session.yourSeat, + ].filter((part): part is string => Boolean(part)); + + return { + roomId: session.roomId, + battleId: session.battleId, + turn: session.turn, + battleStatus: session.battleStatus, + roomStatus: session.roomStatus, + generation: session.generation, + rulesetKey: session.rulesetKey, + yourSeat: session.yourSeat, + summaryLabel: summaryParts.length > 0 ? summaryParts.join(' · ') : '세션 메타 대기 중', + detailLabel: [ + `room ${session.roomId ?? '없음'}`, + `battle ${session.battleId ?? '없음'}`, + `room ${session.roomStatus ?? '없음'}`, + `battle ${session.battleStatus ?? '없음'}`, + ].join(' · '), + lastEventType: session.lastEventType, + lastEventAt: session.lastEventAt, + lastEventLabel: session.lastEventType + ? `마지막 이벤트 ${session.lastEventType} · ${session.lastEventAt ?? '시각 없음'}` + : '마지막 이벤트 없음', + }; +} + +function resolveScreenStatus(view: { + state: PvpSessionClientState; + transport: PvpSessionScreenTransportSummary; + actionRequest: PvpActionRequestView | null; + commandStatus: PvpCommandStatusView | null; + turnResult: PvpTurnResultView | null; +}): { status: PvpSessionScreenStatus; statusLabel: string; detailLabel: string | null; terminal: boolean } { + const battleStatus = view.state.session.battleStatus; + + if (battleStatus === 'finished' || battleStatus === 'abandoned' || view.state.session.terminalResult) { + return { + status: 'terminal', + statusLabel: '전투 종료', + detailLabel: view.turnResult?.summary.terminalResultLabel ?? '최종 결과가 확정되었습니다.', + terminal: true, + }; + } + + if (view.state.transportStatus === 'reconnecting') { + return { + status: 'reconnecting', + statusLabel: '재접속 중', + detailLabel: view.transport.detailLabel, + terminal: false, + }; + } + + if (view.actionRequest?.canInteract) { + return { + status: 'awaiting_input', + statusLabel: '행동 선택 가능', + detailLabel: view.actionRequest.prompt, + terminal: false, + }; + } + + if (view.commandStatus?.submissionState === 'accepted') { + return { + status: 'command_locked', + statusLabel: '명령 제출 완료', + detailLabel: view.commandStatus.detailLabel, + terminal: false, + }; + } + + if (view.commandStatus?.submissionState === 'created') { + return { + status: 'command_locked', + statusLabel: '명령 전송 중', + detailLabel: view.commandStatus.detailLabel, + terminal: false, + }; + } + + if (view.state.transportStatus === 'idle' || view.state.transportStatus === 'connecting' || view.state.transportStatus === 'closed' || view.state.transportStatus === 'error') { + return { + status: 'transport_wait', + statusLabel: summarizeTransportStatus(view.state.transportStatus), + detailLabel: view.transport.detailLabel, + terminal: false, + }; + } + + return { + status: 'idle', + statusLabel: '세션 대기 중', + detailLabel: null, + terminal: false, + }; +} + +export function createPvpSessionScreenView( + state: PvpSessionClientState | null, + payload: PvpTurnResolvedPayloadLike | null = null, +): PvpSessionScreenView | null { + if (!state) { + return null; + } + + const transport = createTransportSummary(state); + const session = createSessionSummary(state); + const actionRequest = createPvpActionRequestView(state); + const commandStatus = createPvpCommandStatusView(state); + const turnResult = createPvpTurnResultView(state, payload); + const hasRenderableContent = actionRequest !== null || turnResult !== null; + const status = resolveScreenStatus({ + state, + transport, + actionRequest, + commandStatus, + turnResult, + }); + + return { + source: 'session', + status: status.status, + statusLabel: status.statusLabel, + detailLabel: status.detailLabel, + emptyStateLabel: hasRenderableContent ? null : '아직 표시할 배틀 요청이나 결과가 없습니다.', + terminal: status.terminal, + hasPendingRequest: state.hasPendingRequest, + hasTurnResult: turnResult !== null, + hasRenderableContent, + transport, + session, + actionRequest, + commandStatus, + turnResult, + }; +} diff --git a/src/pvp/session-store.ts b/src/pvp/session-store.ts new file mode 100644 index 00000000..a02d87ad --- /dev/null +++ b/src/pvp/session-store.ts @@ -0,0 +1,471 @@ +import type { BattleRoomStatus, RoomSeat } from '../server/rooms/index.js'; +import type { PvpGeneration, RulesetKey } from '../server/rules/index.js'; +import type { + BattleActionRequestPayload, + BattleCommand, + BattleCommandAcceptedPayload, + BattleCommandEnvelope, + BattleCommandPhase, + BattleCommandRejectedPayload, + BattleEndedPayload, + BattleReplacementRequestPayload, + BattleRequestKind, + BattleServerEventEnvelope, + BattleSessionPhase, + RoomSnapshotPayload, + ViewerVisibleState, + VisibleBenchPokemon, + VisibleMoveOption, +} from '../server/battle/index.js'; + +type PendingRequestBase = { + kind: BattleRequestKind; + phase: BattleCommandPhase; + turn: number; + deadlineMs: number; + commandSubmitted: boolean; + requestId: string | null; +}; + +export interface PendingActionRequest extends PendingRequestBase { + kind: 'choose_move_or_switch'; + phase: 'awaiting_actions'; + activePokemon?: BattleActionRequestPayload['request']['activePokemon']; + availableMoves?: VisibleMoveOption[]; + availableSwitches?: VisibleBenchPokemon[]; +} + +export interface PendingReplacementRequest extends PendingRequestBase { + kind: 'choose_replacement'; + phase: 'awaiting_replacement'; + faintedSlot: number | null; + availableReplacements?: VisibleBenchPokemon[]; +} + +export type PvpPendingRequest = PendingActionRequest | PendingReplacementRequest; + +export interface PendingCommandState { + clientCommandId: string; + turn: number; + phase: BattleCommandPhase; + command: BattleCommand; + seq: number; + sentAt: string; + status: 'created' | 'accepted' | 'rejected_permanent'; + lockedIn: boolean; +} + +export interface PvpSessionState { + roomId: string | null; + battleId: string | null; + roomStatus: BattleRoomStatus | null; + battleStatus: BattleSessionPhase | null; + generation: PvpGeneration | null; + rulesetKey: RulesetKey | null; + yourSeat: RoomSeat | null; + turn: number | null; + visibleState: ViewerVisibleState | null; + pendingRequest: PvpPendingRequest | null; + pendingCommand: PendingCommandState | null; + lastRejectedCommand: BattleCommandRejectedPayload | null; + lastResolvedTurn: number | null; + terminalResult: BattleEndedPayload | null; + lastServerSeq: number; + lastEventType: BattleServerEventEnvelope['type'] | null; + lastEventAt: string | null; + nextClientSeq: number; + resyncCount: number; +} + +export interface CreateBattleCommandEnvelopeOptions { + clientCommandId: string; + sentAt: string; + command: BattleCommand; +} + +export interface CreatedBattleCommandEnvelope { + state: PvpSessionState; + envelope: BattleCommandEnvelope; +} + +const TERMINAL_PHASES = new Set(['finished', 'abandoned']); + +function clonePendingRequest(request: PvpPendingRequest | null): PvpPendingRequest | null { + if (!request) { + return null; + } + + if (request.kind === 'choose_move_or_switch') { + return { + ...request, + activePokemon: request.activePokemon ? { ...request.activePokemon } : undefined, + availableMoves: request.availableMoves?.map((move) => ({ ...move })), + availableSwitches: request.availableSwitches?.map((slot) => ({ ...slot })), + }; + } + + return { + ...request, + availableReplacements: request.availableReplacements?.map((slot) => ({ ...slot })), + }; +} + +function fromSnapshotPendingRequest(payload: RoomSnapshotPayload): PvpPendingRequest | null { + const request = payload.pendingRequest; + + if (!request) { + return null; + } + + if (request.kind === 'choose_move_or_switch') { + return { + kind: request.kind, + phase: 'awaiting_actions', + turn: payload.turn, + deadlineMs: request.deadlineMs, + commandSubmitted: request.commandSubmitted, + requestId: null, + }; + } + + return { + kind: request.kind, + phase: 'awaiting_replacement', + turn: payload.turn, + deadlineMs: request.deadlineMs, + commandSubmitted: request.commandSubmitted, + requestId: null, + faintedSlot: null, + }; +} + +function fromActionRequest(payload: BattleActionRequestPayload): PendingActionRequest { + return { + kind: 'choose_move_or_switch', + phase: 'awaiting_actions', + turn: payload.turn, + deadlineMs: payload.deadlineMs, + commandSubmitted: false, + requestId: payload.requestId, + activePokemon: { ...payload.request.activePokemon }, + availableMoves: payload.request.availableMoves.map((move) => ({ ...move })), + availableSwitches: payload.request.availableSwitches.map((slot) => ({ ...slot })), + }; +} + +function fromReplacementRequest(payload: BattleReplacementRequestPayload): PendingReplacementRequest { + return { + kind: 'choose_replacement', + phase: 'awaiting_replacement', + turn: payload.turn, + deadlineMs: payload.deadlineMs, + commandSubmitted: false, + requestId: payload.requestId, + faintedSlot: payload.faintedSlot, + availableReplacements: payload.availableReplacements.map((slot) => ({ ...slot })), + }; +} + +function syncServerEnvelopeMeta( + state: PvpSessionState, + event: BattleServerEventEnvelope, +): Pick { + return { + roomId: event.roomId, + battleId: event.battleId, + lastServerSeq: event.seq, + lastEventType: event.type, + lastEventAt: event.sentAt, + }; +} + +function setPendingRequestCommandSubmitted( + request: PvpPendingRequest | null, + commandSubmitted: boolean, +): PvpPendingRequest | null { + if (!request) { + return null; + } + + if (request.kind === 'choose_move_or_switch') { + return { + ...request, + activePokemon: request.activePokemon ? { ...request.activePokemon } : undefined, + availableMoves: request.availableMoves?.map((move) => ({ ...move })), + availableSwitches: request.availableSwitches?.map((slot) => ({ ...slot })), + commandSubmitted, + }; + } + + return { + ...request, + availableReplacements: request.availableReplacements?.map((slot) => ({ ...slot })), + commandSubmitted, + }; +} + +function assertCanCreateCommand(state: PvpSessionState): asserts state is PvpSessionState & { + roomId: string; + battleId: string; + pendingRequest: PvpPendingRequest; +} { + if (!state.roomId || !state.battleId) { + throw new Error('Cannot build battle command envelope without active room and battle ids.'); + } + + if (!state.pendingRequest || TERMINAL_PHASES.has(state.battleStatus ?? 'finished')) { + throw new Error('Cannot build battle command envelope without a current pending request.'); + } + + if (isCommandLocked(state)) { + throw new Error('Cannot build battle command envelope while command input is locked.'); + } +} + +function validateActionCommand(request: PendingActionRequest, command: BattleCommand): void { + if (command.type === 'forfeit') { + return; + } + + if (command.type === 'choose_replacement') { + throw new Error('Command is incompatible with the current pending request.'); + } + + if (command.type === 'choose_move' && request.availableMoves) { + const move = request.availableMoves.find((entry) => entry.slot === command.moveSlot); + if (!move || move.disabled) { + throw new Error(`Move slot ${command.moveSlot} is not valid for the current pending request.`); + } + } + + if (command.type === 'choose_switch' && request.availableSwitches) { + const target = request.availableSwitches.find((entry) => entry.slot === command.targetSlot); + if (!target || target.fainted) { + throw new Error(`Switch slot ${command.targetSlot} is not valid for the current pending request.`); + } + } +} + +function validateReplacementCommand(request: PendingReplacementRequest, command: BattleCommand): void { + if (command.type === 'forfeit') { + return; + } + + if (command.type !== 'choose_replacement') { + throw new Error('Command is incompatible with the current pending request.'); + } + + if (request.availableReplacements) { + const target = request.availableReplacements.find((entry) => entry.slot === command.targetSlot); + if (!target || target.fainted) { + throw new Error(`Replacement slot ${command.targetSlot} is not valid for the current pending request.`); + } + } +} + +export function createPvpSessionState(): PvpSessionState { + return { + roomId: null, + battleId: null, + roomStatus: null, + battleStatus: null, + generation: null, + rulesetKey: null, + yourSeat: null, + turn: null, + visibleState: null, + pendingRequest: null, + pendingCommand: null, + lastRejectedCommand: null, + lastResolvedTurn: null, + terminalResult: null, + lastServerSeq: 0, + lastEventType: null, + lastEventAt: null, + nextClientSeq: 1, + resyncCount: 0, + }; +} + +export function hasPendingAction(state: PvpSessionState): boolean { + return state.pendingRequest !== null && !TERMINAL_PHASES.has(state.battleStatus ?? 'finished'); +} + +export function isCommandLocked(state: PvpSessionState): boolean { + return Boolean(state.pendingCommand) || state.pendingRequest?.commandSubmitted === true; +} + +export function applyPvpServerEvent( + state: PvpSessionState, + event: BattleServerEventEnvelope, +): PvpSessionState { + const envelopeMeta = syncServerEnvelopeMeta(state, event); + + switch (event.type) { + case 'room.snapshot': { + const clearedTransient = state.pendingCommand !== null || state.lastRejectedCommand !== null; + return { + ...state, + ...envelopeMeta, + roomStatus: event.payload.roomStatus, + battleStatus: event.payload.battleStatus, + generation: event.payload.generation, + rulesetKey: event.payload.rulesetKey, + yourSeat: event.payload.yourSeat, + turn: event.payload.turn, + visibleState: structuredClone(event.payload.visibleState), + pendingRequest: fromSnapshotPendingRequest(event.payload), + pendingCommand: null, + lastRejectedCommand: null, + lastResolvedTurn: state.lastResolvedTurn, + terminalResult: TERMINAL_PHASES.has(event.payload.battleStatus) ? state.terminalResult : null, + resyncCount: clearedTransient ? state.resyncCount + 1 : state.resyncCount, + }; + } + + case 'battle.request_action': + return { + ...state, + ...envelopeMeta, + battleStatus: 'awaiting_actions', + turn: event.payload.turn, + pendingRequest: fromActionRequest(event.payload), + pendingCommand: null, + lastRejectedCommand: null, + terminalResult: null, + }; + + case 'battle.command_accepted': { + const pendingRequest = setPendingRequestCommandSubmitted(state.pendingRequest, true); + + const pendingCommand = state.pendingCommand + && state.pendingCommand.clientCommandId === event.payload.clientCommandId + ? { + ...state.pendingCommand, + status: 'accepted' as const, + lockedIn: event.payload.lockedIn, + } + : state.pendingCommand; + + return { + ...state, + ...envelopeMeta, + pendingRequest, + pendingCommand, + lastRejectedCommand: null, + }; + } + + case 'battle.command_rejected': { + const retryable = event.payload.retryable; + const pendingRequest = setPendingRequestCommandSubmitted( + state.pendingRequest, + retryable ? false : state.pendingRequest?.commandSubmitted ?? false, + ); + + const pendingCommand = retryable + ? null + : state.pendingCommand + && state.pendingCommand.clientCommandId === event.payload.clientCommandId + ? { + ...state.pendingCommand, + status: 'rejected_permanent' as const, + lockedIn: true, + } + : state.pendingCommand; + + return { + ...state, + ...envelopeMeta, + pendingRequest, + pendingCommand, + lastRejectedCommand: { ...event.payload }, + }; + } + + case 'battle.turn_resolved': + return { + ...state, + ...envelopeMeta, + battleStatus: event.payload.nextPhase, + turn: event.payload.turn, + visibleState: structuredClone(event.payload.postTurnVisibleState), + pendingRequest: null, + pendingCommand: null, + lastRejectedCommand: null, + lastResolvedTurn: event.payload.turn, + terminalResult: event.payload.nextPhase === 'finished' ? state.terminalResult : null, + }; + + case 'battle.force_replacement': + return { + ...state, + ...envelopeMeta, + battleStatus: 'awaiting_replacement', + turn: event.payload.turn, + pendingRequest: fromReplacementRequest(event.payload), + pendingCommand: null, + lastRejectedCommand: null, + terminalResult: null, + }; + + case 'battle.ended': + return { + ...state, + ...envelopeMeta, + battleStatus: 'finished', + pendingRequest: null, + pendingCommand: null, + lastRejectedCommand: null, + terminalResult: { ...event.payload }, + }; + } +} + +export function createBattleCommandEnvelope( + state: PvpSessionState, + options: CreateBattleCommandEnvelopeOptions, +): CreatedBattleCommandEnvelope { + assertCanCreateCommand(state); + + if (state.pendingRequest.kind === 'choose_move_or_switch') { + validateActionCommand(state.pendingRequest, options.command); + } else { + validateReplacementCommand(state.pendingRequest, options.command); + } + + const envelope: BattleCommandEnvelope = { + type: 'battle.command', + roomId: state.roomId, + battleId: state.battleId, + seq: state.nextClientSeq, + sentAt: options.sentAt, + payload: { + clientCommandId: options.clientCommandId, + turn: state.pendingRequest.turn, + phase: state.pendingRequest.phase, + command: structuredClone(options.command), + }, + }; + + const nextState: PvpSessionState = { + ...state, + nextClientSeq: state.nextClientSeq + 1, + pendingCommand: { + clientCommandId: options.clientCommandId, + turn: state.pendingRequest.turn, + phase: state.pendingRequest.phase, + command: structuredClone(options.command), + seq: envelope.seq, + sentAt: options.sentAt, + status: 'created', + lockedIn: false, + }, + lastRejectedCommand: null, + }; + + return { + state: nextState, + envelope, + }; +} diff --git a/src/pvp/session-terminal-cli.ts b/src/pvp/session-terminal-cli.ts new file mode 100644 index 00000000..83b40259 --- /dev/null +++ b/src/pvp/session-terminal-cli.ts @@ -0,0 +1,193 @@ +import type { + PvpSessionTerminalRunnerLike, + PvpSessionTerminalRunnerState, +} from './session-terminal-runner.js'; + +export type PvpSessionTerminalCliMaybePromise = T | Promise; +export type PvpSessionTerminalCliInputListener = (token: string) => void; + +export interface PvpSessionTerminalCliInputSource { + subscribe(listener: PvpSessionTerminalCliInputListener): () => void; +} + +export interface PvpSessionTerminalCliScreenOutput { + repaint(screen: string, state: PvpSessionTerminalRunnerState): void; +} + +export interface PvpSessionTerminalCliBootstrapHooks { + connect?: () => PvpSessionTerminalCliMaybePromise; + disconnect?: () => PvpSessionTerminalCliMaybePromise; +} + +export interface PvpSessionTerminalCliState { + running: boolean; + runnerState: PvpSessionTerminalRunnerState; +} + +export interface CreatePvpSessionTerminalCliOptions { + runner: PvpSessionTerminalRunnerLike; + input: PvpSessionTerminalCliInputSource; + output: PvpSessionTerminalCliScreenOutput; + bootstrap?: PvpSessionTerminalCliBootstrapHooks; + normalizeInputToken?: (token: string) => string; +} + +function cloneRunnerState(state: PvpSessionTerminalRunnerState): PvpSessionTerminalRunnerState { + return structuredClone(state); +} + +function createCliState( + runnerState: PvpSessionTerminalRunnerState, + options: { + running: boolean; + }, +): PvpSessionTerminalCliState { + return { + running: options.running, + runnerState: cloneRunnerState(runnerState), + }; +} + +function defaultNormalizeInputToken(token: string): string { + return token.trim(); +} + +export class PvpSessionTerminalCli { + private readonly runner: PvpSessionTerminalRunnerLike; + + private readonly input: PvpSessionTerminalCliInputSource; + + private readonly output: PvpSessionTerminalCliScreenOutput; + + private readonly bootstrap: PvpSessionTerminalCliBootstrapHooks; + + private readonly normalizeInputToken: (token: string) => string; + + private unsubscribeRunner: (() => void) | null = null; + + private unsubscribeInput: (() => void) | null = null; + + private state: PvpSessionTerminalCliState; + + private running = false; + + constructor(options: CreatePvpSessionTerminalCliOptions) { + this.runner = options.runner; + this.input = options.input; + this.output = options.output; + this.bootstrap = options.bootstrap ?? {}; + this.normalizeInputToken = options.normalizeInputToken ?? defaultNormalizeInputToken; + this.state = createCliState(this.runner.getState(), { + running: false, + }); + } + + async start(): Promise { + if (this.running) { + return this.getState(); + } + + let connectCompleted = false; + let runnerStarted = false; + let runnerUnsubscribe: (() => void) | null = null; + let inputUnsubscribe: (() => void) | null = null; + + try { + await this.bootstrap.connect?.(); + connectCompleted = true; + + const startedRunnerState = this.runner.start(); + runnerStarted = true; + this.running = true; + this.state = createCliState(startedRunnerState, { + running: true, + }); + + runnerUnsubscribe = this.runner.subscribe((runnerState) => { + this.state = createCliState(runnerState, { + running: this.running, + }); + this.output.repaint(runnerState.screen, cloneRunnerState(runnerState)); + }); + + inputUnsubscribe = this.input.subscribe((token) => { + this.handleInputToken(token); + }); + + this.unsubscribeRunner = runnerUnsubscribe; + this.unsubscribeInput = inputUnsubscribe; + + return this.getState(); + } catch (error) { + this.running = false; + this.unsubscribeInput = null; + this.unsubscribeRunner = null; + + inputUnsubscribe?.(); + runnerUnsubscribe?.(); + + if (runnerStarted) { + this.runner.stop(); + } + + if (connectCompleted) { + await this.bootstrap.disconnect?.(); + } + + this.state = createCliState(this.runner.getState(), { + running: false, + }); + throw error; + } + } + + async stop(): Promise { + if (!this.running && !this.unsubscribeInput && !this.unsubscribeRunner) { + return this.getState(); + } + + this.running = false; + + const unsubscribeInput = this.unsubscribeInput; + this.unsubscribeInput = null; + unsubscribeInput?.(); + + const unsubscribeRunner = this.unsubscribeRunner; + this.unsubscribeRunner = null; + unsubscribeRunner?.(); + + const stoppedRunnerState = this.runner.stop(); + this.state = createCliState(stoppedRunnerState, { + running: false, + }); + + await this.bootstrap.disconnect?.(); + return this.getState(); + } + + getState(): PvpSessionTerminalCliState { + return { + running: this.state.running, + runnerState: cloneRunnerState(this.state.runnerState), + }; + } + + private handleInputToken(rawToken: string): void { + if (!this.running) { + return; + } + + const token = this.normalizeInputToken(rawToken); + if (!token) { + return; + } + + this.runner.submitInputToken(token); + } +} + +export function createPvpSessionTerminalCli( + options: CreatePvpSessionTerminalCliOptions, +): PvpSessionTerminalCli { + return new PvpSessionTerminalCli(options); +} diff --git a/src/pvp/session-terminal-controller.ts b/src/pvp/session-terminal-controller.ts new file mode 100644 index 00000000..311e16ff --- /dev/null +++ b/src/pvp/session-terminal-controller.ts @@ -0,0 +1,280 @@ +import type { BattleCommand } from '../server/battle/index.js'; +import { + createPvpActionRequestView, + type PvpActionRequestMenuEntry, + type PvpActionRequestMenuSection, + type PvpActionRequestView, +} from './action-request-view.js'; +import { + renderPvpSessionClientScreen, +} from './session-screen-renderer.js'; +import type { + PvpSessionClient, + PvpSessionClientState, + SendPvpSessionBattleCommandResult, +} from './session-client.js'; +import type { CreateBattleCommandEnvelopeOptions } from './session-store.js'; +import type { PvpTurnResolvedPayloadLike } from './turn-result-view.js'; + +export interface PvpSessionTerminalInputEntry { + section: Pick; + key: string; + kind: PvpActionRequestMenuEntry['kind']; + label: string; + detail: string | null; + inputToken: string; + enabled: boolean; + disabledReason: string | null; + command: BattleCommand; +} + +export interface PvpSessionTerminalSnapshot { + state: PvpSessionClientState | null; + screen: string; + actionRequest: PvpActionRequestView | null; + inputEntries: PvpSessionTerminalInputEntry[]; + availableInputTokens: string[]; +} + +export interface PvpSessionTerminalResolvedInputTokenResult { + status: 'resolved'; + token: string; + command: BattleCommand; + entry: PvpSessionTerminalInputEntry; + snapshot: PvpSessionTerminalSnapshot; +} + +export interface PvpSessionTerminalInputTokenFailureResult { + status: 'invalid_token' | 'no_request' | 'locked' | 'transport_not_ready'; + token: string; + message: string; + snapshot: PvpSessionTerminalSnapshot; +} + +export type PvpSessionTerminalInputTokenResult = + | PvpSessionTerminalResolvedInputTokenResult + | PvpSessionTerminalInputTokenFailureResult; + +export interface PvpSessionTerminalSubmitSuccessResult { + status: 'submitted'; + token: string; + command: BattleCommand; + entry: PvpSessionTerminalInputEntry; + snapshot: PvpSessionTerminalSnapshot; + sendOptions: CreateBattleCommandEnvelopeOptions; + sendResult: SendPvpSessionBattleCommandResult; +} + +export interface PvpSessionTerminalSubmitFailureResult { + status: PvpSessionTerminalInputTokenFailureResult['status'] | 'unavailable'; + token: string; + message: string; + snapshot: PvpSessionTerminalSnapshot; + cause?: unknown; +} + +export type PvpSessionTerminalSubmitResult = + | PvpSessionTerminalSubmitSuccessResult + | PvpSessionTerminalSubmitFailureResult; + +export interface PvpSessionTerminalClient { + getState(): PvpSessionClientState; + sendBattleCommand(options: CreateBattleCommandEnvelopeOptions): SendPvpSessionBattleCommandResult; +} + +export interface CreatePvpSessionTerminalControllerOptions { + sessionClient: PvpSessionTerminalClientLike; + now?: () => Date; + createClientCommandId?: (state: PvpSessionClientState, command: BattleCommand) => string; +} + +function toTerminalInputEntry( + section: PvpActionRequestMenuSection, + entry: PvpActionRequestMenuEntry, +): PvpSessionTerminalInputEntry { + return { + section: { + id: section.id, + title: section.title, + }, + key: entry.key, + kind: entry.kind, + label: entry.label, + detail: entry.detail, + inputToken: entry.inputToken, + enabled: entry.enabled, + disabledReason: entry.disabledReason, + command: structuredClone(entry.command), + }; +} + +function createInputEntries(actionRequest: PvpActionRequestView | null): PvpSessionTerminalInputEntry[] { + if (!actionRequest) { + return []; + } + + return actionRequest.sections.flatMap((section) => section.entries.map((entry) => toTerminalInputEntry(section, entry))); +} + +function createAvailableInputTokens(entries: PvpSessionTerminalInputEntry[]): string[] { + const seen = new Set(); + const tokens: string[] = []; + + for (const entry of entries) { + if (!entry.enabled || seen.has(entry.inputToken)) { + continue; + } + + seen.add(entry.inputToken); + tokens.push(entry.inputToken); + } + + return tokens; +} + +function normalizeInputToken(token: string): string { + return token.trim(); +} + +function defaultClientCommandId(state: PvpSessionClientState, command: BattleCommand): string { + const battleId = state.session.battleId ?? 'battle'; + const request = state.session.pendingRequest; + const requestKey = request?.requestId ?? request?.kind ?? 'request'; + const commandKey = command.type === 'forfeit' + ? 'forfeit' + : command.type === 'choose_move' + ? `move-${command.moveSlot}` + : `slot-${command.targetSlot}`; + + return `terminal-${battleId}-${requestKey}-${state.session.nextClientSeq}-${commandKey}`; +} + +function createFailureResult( + status: PvpSessionTerminalInputTokenFailureResult['status'], + token: string, + message: string, + snapshot: PvpSessionTerminalSnapshot, +): PvpSessionTerminalInputTokenFailureResult { + return { + status, + token, + message, + snapshot, + }; +} + +export function createPvpSessionTerminalSnapshot( + state: PvpSessionClientState | null, + payloadOverride: PvpTurnResolvedPayloadLike | null = null, +): PvpSessionTerminalSnapshot { + const actionRequest = state ? createPvpActionRequestView(state) : null; + const inputEntries = createInputEntries(actionRequest); + + return { + state: state ? structuredClone(state) : null, + screen: renderPvpSessionClientScreen(state, payloadOverride), + actionRequest, + inputEntries, + availableInputTokens: createAvailableInputTokens(inputEntries), + }; +} + +export function resolvePvpSessionTerminalInputToken( + state: PvpSessionClientState | null, + token: string, + payloadOverride: PvpTurnResolvedPayloadLike | null = null, +): PvpSessionTerminalInputTokenResult { + const normalizedToken = normalizeInputToken(token); + const snapshot = createPvpSessionTerminalSnapshot(state, payloadOverride); + + if (!snapshot.actionRequest) { + return createFailureResult('no_request', normalizedToken, '현재 처리할 배틀 요청이 없습니다.', snapshot); + } + + if (state?.transportStatus !== 'connected') { + return createFailureResult('transport_not_ready', normalizedToken, '실시간 전송 연결이 아직 준비되지 않았습니다.', snapshot); + } + + if (!snapshot.actionRequest.canInteract || snapshot.actionRequest.locked) { + return createFailureResult('locked', normalizedToken, '현재 요청은 잠겨 있어 명령을 제출할 수 없습니다.', snapshot); + } + + const entry = snapshot.inputEntries.find((candidate) => candidate.enabled && candidate.inputToken === normalizedToken); + if (!entry) { + return createFailureResult('invalid_token', normalizedToken, '현재 요청에서 사용할 수 없는 입력 토큰입니다.', snapshot); + } + + return { + status: 'resolved', + token: normalizedToken, + command: structuredClone(entry.command), + entry, + snapshot, + }; +} + +export class PvpSessionTerminalController { + private readonly sessionClient: PvpSessionTerminalClient; + + private readonly now: () => Date; + + private readonly createClientCommandId: (state: PvpSessionClientState, command: BattleCommand) => string; + + constructor(options: CreatePvpSessionTerminalControllerOptions) { + this.sessionClient = options.sessionClient; + this.now = options.now ?? (() => new Date()); + this.createClientCommandId = options.createClientCommandId ?? defaultClientCommandId; + } + + getSnapshot(payloadOverride: PvpTurnResolvedPayloadLike | null = null): PvpSessionTerminalSnapshot { + return createPvpSessionTerminalSnapshot(this.sessionClient.getState(), payloadOverride); + } + + resolveInputToken(token: string, payloadOverride: PvpTurnResolvedPayloadLike | null = null): PvpSessionTerminalInputTokenResult { + return resolvePvpSessionTerminalInputToken(this.sessionClient.getState(), token, payloadOverride); + } + + submitInputToken(token: string): PvpSessionTerminalSubmitResult { + const state = this.sessionClient.getState(); + const resolved = resolvePvpSessionTerminalInputToken(state, token); + if (resolved.status !== 'resolved') { + return resolved; + } + + const sendOptions: CreateBattleCommandEnvelopeOptions = { + clientCommandId: this.createClientCommandId(state, resolved.command), + sentAt: this.now().toISOString(), + command: structuredClone(resolved.command), + }; + + try { + const sendResult = this.sessionClient.sendBattleCommand(sendOptions); + return { + status: 'submitted', + token: resolved.token, + command: structuredClone(resolved.command), + entry: resolved.entry, + snapshot: resolved.snapshot, + sendOptions, + sendResult, + }; + } catch (error) { + return { + status: 'unavailable', + token: resolved.token, + message: error instanceof Error ? error.message : '명령 전송 중 알 수 없는 오류가 발생했습니다.', + snapshot: resolved.snapshot, + cause: error, + }; + } + } +} + +export function createPvpSessionTerminalController( + options: CreatePvpSessionTerminalControllerOptions, +): PvpSessionTerminalController { + return new PvpSessionTerminalController(options); +} + +export type PvpSessionTerminalClientLike = Pick; +export type PvpSessionTerminalControllerLike = Pick; diff --git a/src/pvp/session-terminal-runner.ts b/src/pvp/session-terminal-runner.ts new file mode 100644 index 00000000..51bf6f0f --- /dev/null +++ b/src/pvp/session-terminal-runner.ts @@ -0,0 +1,206 @@ +import type { BattleCommand } from '../server/battle/index.js'; +import { + createPvpSessionTerminalController, + type CreatePvpSessionTerminalControllerOptions, + type PvpSessionTerminalClientLike, + type PvpSessionTerminalSnapshot, + type PvpSessionTerminalSubmitFailureResult, + type PvpSessionTerminalSubmitResult, + type PvpSessionTerminalSubmitSuccessResult, +} from './session-terminal-controller.js'; +import type { PvpSessionClient, PvpSessionClientState, PvpSessionClientStateListener } from './session-client.js'; + +export interface PvpSessionTerminalRunnerState { + running: boolean; + revision: number; + snapshot: PvpSessionTerminalSnapshot; + screen: string; + availableInputTokens: string[]; + lastSubmitResult: PvpSessionTerminalSubmitResult | null; +} + +export type PvpSessionTerminalRunnerStateListener = (state: PvpSessionTerminalRunnerState) => void; + +export type PvpSessionTerminalRunnerSessionClientLike = PvpSessionTerminalClientLike & Pick; + +export interface CreatePvpSessionTerminalRunnerOptions extends Omit { + sessionClient: PvpSessionTerminalRunnerSessionClientLike; +} + +function cloneRunnerState(state: PvpSessionTerminalRunnerState): PvpSessionTerminalRunnerState { + return structuredClone(state); +} + +function cloneSnapshot(snapshot: PvpSessionTerminalSnapshot): PvpSessionTerminalSnapshot { + return structuredClone(snapshot); +} + +function cloneSubmitResult(result: PvpSessionTerminalSubmitResult | null): PvpSessionTerminalSubmitResult | null { + return result ? structuredClone(result) : null; +} + +function createRunnerState( + snapshot: PvpSessionTerminalSnapshot, + options: { + running: boolean; + revision: number; + lastSubmitResult?: PvpSessionTerminalSubmitResult | null; + }, +): PvpSessionTerminalRunnerState { + const clonedSnapshot = cloneSnapshot(snapshot); + + return { + running: options.running, + revision: options.revision, + snapshot: clonedSnapshot, + screen: clonedSnapshot.screen, + availableInputTokens: [...clonedSnapshot.availableInputTokens], + lastSubmitResult: cloneSubmitResult(options.lastSubmitResult ?? null), + }; +} + +function syncSubmitResultSnapshot( + result: PvpSessionTerminalSubmitResult, + snapshot: PvpSessionTerminalSnapshot, +): PvpSessionTerminalSubmitResult { + const syncedSnapshot = cloneSnapshot(snapshot); + + if (result.status === 'submitted') { + const success: PvpSessionTerminalSubmitSuccessResult = { + ...structuredClone(result), + command: structuredClone(result.command), + entry: structuredClone(result.entry), + sendOptions: structuredClone(result.sendOptions), + sendResult: structuredClone(result.sendResult), + snapshot: syncedSnapshot, + }; + + return success; + } + + const failure: PvpSessionTerminalSubmitFailureResult = { + ...structuredClone(result), + snapshot: syncedSnapshot, + }; + + return failure; +} + +export class PvpSessionTerminalRunner { + private readonly sessionClient: PvpSessionTerminalRunnerSessionClientLike; + + private readonly listeners = new Set(); + + private readonly controller: ReturnType; + + private unsubscribeSession: (() => void) | null = null; + + private state: PvpSessionTerminalRunnerState; + + constructor(options: CreatePvpSessionTerminalRunnerOptions) { + this.sessionClient = options.sessionClient; + this.controller = createPvpSessionTerminalController({ + sessionClient: options.sessionClient, + now: options.now, + createClientCommandId: options.createClientCommandId, + }); + this.state = createRunnerState(this.controller.getSnapshot(), { + running: false, + revision: 0, + lastSubmitResult: null, + }); + } + + start(): PvpSessionTerminalRunnerState { + if (this.unsubscribeSession) { + return this.getState(); + } + + this.state = { + ...this.state, + running: true, + }; + + const listener: PvpSessionClientStateListener = (_sessionState: PvpSessionClientState) => { + this.refreshFromSession(); + }; + + this.unsubscribeSession = this.sessionClient.subscribe(listener); + return this.getState(); + } + + stop(): PvpSessionTerminalRunnerState { + if (!this.unsubscribeSession) { + return this.getState(); + } + + const unsubscribe = this.unsubscribeSession; + this.unsubscribeSession = null; + unsubscribe(); + + this.state = { + ...this.state, + running: false, + revision: this.state.revision + 1, + }; + this.emit(); + return this.getState(); + } + + getState(): PvpSessionTerminalRunnerState { + return cloneRunnerState(this.state); + } + + subscribe(listener: PvpSessionTerminalRunnerStateListener): () => void { + this.listeners.add(listener); + listener(this.getState()); + + return () => { + this.listeners.delete(listener); + }; + } + + submitInputToken(token: string): PvpSessionTerminalSubmitResult { + const submitResult = this.controller.submitInputToken(token); + const snapshot = this.controller.getSnapshot(); + const syncedResult = syncSubmitResultSnapshot(submitResult, snapshot); + + this.state = createRunnerState(snapshot, { + running: this.state.running, + revision: this.state.revision + 1, + lastSubmitResult: syncedResult, + }); + this.emit(); + return cloneSubmitResult(syncedResult) ?? syncedResult; + } + + private refreshFromSession(): void { + const snapshot = this.controller.getSnapshot(); + this.state = createRunnerState(snapshot, { + running: this.state.running, + revision: this.state.revision + 1, + lastSubmitResult: this.state.lastSubmitResult, + }); + this.emit(); + } + + private emit(): void { + const snapshot = this.getState(); + for (const listener of this.listeners) { + listener(snapshot); + } + } +} + +export function createPvpSessionTerminalRunner( + options: CreatePvpSessionTerminalRunnerOptions, +): PvpSessionTerminalRunner { + return new PvpSessionTerminalRunner(options); +} + +export type PvpSessionTerminalRunnerLike = Pick< + PvpSessionTerminalRunner, + 'start' | 'stop' | 'getState' | 'subscribe' | 'submitInputToken' +>; + +export type PvpSessionTerminalRunnerCommand = BattleCommand; diff --git a/src/pvp/session-terminal-stdio.ts b/src/pvp/session-terminal-stdio.ts new file mode 100644 index 00000000..4bf7e33b --- /dev/null +++ b/src/pvp/session-terminal-stdio.ts @@ -0,0 +1,364 @@ +import { createInterface as createNodeReadlineInterface } from 'node:readline'; + +import { CLEAR_SCREEN, CURSOR_HOME, HIDE_CURSOR, SHOW_CURSOR } from '../battle-tui/ansi.js'; +import type { + PvpSessionTerminalCliInputListener, + PvpSessionTerminalCliInputSource, + PvpSessionTerminalCliScreenOutput, +} from './session-terminal-cli.js'; +import type { PvpSessionTerminalRunnerState } from './session-terminal-runner.js'; + +export type PvpSessionTerminalStdioAbortReason = 'sigint' | 'eof' | 'signal'; + +export interface PvpSessionTerminalStdioAbortEvent { + reason: PvpSessionTerminalStdioAbortReason; + signal?: string; +} + +export interface PvpSessionTerminalStdioInput { + isTTY?: boolean; + on(event: 'data', listener: (chunk: string | Buffer) => void): this; + off?(event: 'data', listener: (chunk: string | Buffer) => void): this; + removeListener?(event: 'data', listener: (chunk: string | Buffer) => void): this; + setRawMode?(enabled: boolean): void; + setEncoding?(encoding: BufferEncoding): void; + resume(): void; + pause(): void; +} + +export interface PvpSessionTerminalStdioOutput { + write(chunk: string): boolean; +} + +export interface PvpSessionTerminalStdioSignalTarget { + on(event: string, listener: () => void): unknown; + off?(event: string, listener: () => void): unknown; + removeListener?(event: string, listener: () => void): unknown; +} + +export interface PvpSessionTerminalStdioReadlineLike { + on(event: 'line', listener: (line: string) => void): this; + on(event: 'close', listener: () => void): this; + off?(event: 'line' | 'close', listener: ((line: string) => void) | (() => void)): this; + removeListener?(event: 'line' | 'close', listener: ((line: string) => void) | (() => void)): this; + close(): void; +} + +export interface CreatePvpSessionTerminalStdioAdapterOptions { + stdin?: PvpSessionTerminalStdioInput; + stdout?: PvpSessionTerminalStdioOutput; + signalTarget?: PvpSessionTerminalStdioSignalTarget; + createReadlineInterface?: () => PvpSessionTerminalStdioReadlineLike; + onAbort?: (event: PvpSessionTerminalStdioAbortEvent) => void; + prompt?: string; +} + +export interface PvpSessionTerminalStdioAbortHandlerTarget { + setAbortHandler(handler: (() => void) | null): void; +} + +function detachListener void>( + target: { + off?: (event: TEvent, listener: TListener) => unknown; + removeListener?: (event: TEvent, listener: TListener) => unknown; + }, + event: TEvent, + listener: TListener, +): void { + if (typeof target.off === 'function') { + target.off(event, listener); + return; + } + + if (typeof target.removeListener === 'function') { + target.removeListener(event, listener); + } +} + +function renderScreen(screen: string, prompt: string, buffer: string): string { + const promptLine = `${prompt}${buffer}`; + return `${HIDE_CURSOR}${CLEAR_SCREEN}${CURSOR_HOME}${screen}\n\n${promptLine}`; +} + +function normalizeRawToken(rawToken: string): string { + return rawToken.trim(); +} + +export class PvpSessionTerminalStdioAdapter + implements PvpSessionTerminalCliInputSource, PvpSessionTerminalCliScreenOutput, PvpSessionTerminalStdioAbortHandlerTarget { + private readonly stdin: PvpSessionTerminalStdioInput; + + private readonly stdout: PvpSessionTerminalStdioOutput; + + private readonly signalTarget: PvpSessionTerminalStdioSignalTarget | null; + + private readonly createReadlineInterface: () => PvpSessionTerminalStdioReadlineLike; + + private readonly onAbort: ((event: PvpSessionTerminalStdioAbortEvent) => void) | null; + + private readonly prompt: string; + + private readonly listeners = new Set(); + + private readonly ttyDataListener = (chunk: string | Buffer): void => { + this.handleTtyChunk(typeof chunk === 'string' ? chunk : chunk.toString('utf8')); + }; + + private readonly readlineLineListener = (line: string): void => { + this.emitResolvedToken(line); + }; + + private readonly readlineCloseListener = (): void => { + if (this.closingReadline) { + return; + } + + this.dispatchAbort({ reason: 'eof' }); + }; + + private readonly signalListeners = new Map void>(); + + private latestScreen = ''; + + private latestState: PvpSessionTerminalRunnerState | null = null; + + private buffer = ''; + + private active = false; + + private readline: PvpSessionTerminalStdioReadlineLike | null = null; + + private closingReadline = false; + + private abortHandler: (() => void) | null = null; + + constructor(options: CreatePvpSessionTerminalStdioAdapterOptions = {}) { + this.stdin = options.stdin ?? (process.stdin as unknown as PvpSessionTerminalStdioInput); + this.stdout = options.stdout ?? (process.stdout as unknown as PvpSessionTerminalStdioOutput); + this.signalTarget = options.signalTarget ?? (process as unknown as PvpSessionTerminalStdioSignalTarget); + this.createReadlineInterface = options.createReadlineInterface + ?? (() => createNodeReadlineInterface({ + input: this.stdin as unknown as NodeJS.ReadableStream, + output: this.stdout as unknown as NodeJS.WritableStream, + terminal: false, + }) as unknown as PvpSessionTerminalStdioReadlineLike); + this.onAbort = options.onAbort ?? null; + this.prompt = options.prompt ?? '> '; + } + + subscribe(listener: PvpSessionTerminalCliInputListener): () => void { + this.listeners.add(listener); + + if (!this.active) { + this.activate(); + } + + return () => { + this.listeners.delete(listener); + if (this.listeners.size === 0) { + this.deactivate(); + } + }; + } + + repaint(screen: string, state: PvpSessionTerminalRunnerState): void { + this.latestScreen = screen; + this.latestState = structuredClone(state); + + if (this.active) { + this.stdout.write(renderScreen(this.latestScreen, this.prompt, this.buffer)); + } + } + + setAbortHandler(handler: (() => void) | null): void { + this.abortHandler = handler; + } + + private activate(): void { + this.active = true; + this.buffer = ''; + + if (this.stdin.isTTY) { + this.stdin.setEncoding?.('utf8'); + this.stdin.setRawMode?.(true); + this.stdin.resume(); + this.stdin.on('data', this.ttyDataListener); + } else { + this.readline = this.createReadlineInterface(); + this.readline.on('line', this.readlineLineListener); + this.readline.on('close', this.readlineCloseListener); + } + + this.bindSignals(); + this.stdout.write(renderScreen(this.latestScreen, this.prompt, this.buffer)); + } + + private deactivate(): void { + if (!this.active) { + return; + } + + this.active = false; + this.unbindSignals(); + + if (this.stdin.isTTY) { + detachListener(this.stdin, 'data', this.ttyDataListener); + this.stdin.setRawMode?.(false); + this.stdin.pause(); + } + + if (this.readline) { + this.closingReadline = true; + detachListener(this.readline, 'line', this.readlineLineListener); + detachListener(this.readline, 'close', this.readlineCloseListener); + this.readline.close(); + this.readline = null; + this.closingReadline = false; + } + + this.stdout.write(SHOW_CURSOR); + this.buffer = ''; + } + + private bindSignals(): void { + if (!this.signalTarget || this.signalListeners.size > 0) { + return; + } + + for (const signal of ['SIGINT', 'SIGTERM']) { + const listener = (): void => { + this.dispatchAbort({ + reason: signal === 'SIGINT' ? 'sigint' : 'signal', + signal, + }); + }; + + this.signalListeners.set(signal, listener); + this.signalTarget.on(signal, listener); + } + } + + private unbindSignals(): void { + if (!this.signalTarget) { + return; + } + + for (const [signal, listener] of this.signalListeners.entries()) { + detachListener(this.signalTarget, signal, listener); + } + + this.signalListeners.clear(); + } + + private handleTtyChunk(chunk: string): void { + if (!chunk) { + return; + } + + const trimmedChunk = normalizeRawToken(chunk); + if (trimmedChunk && !chunk.includes('\n') && !chunk.includes('\r')) { + const immediateToken = this.resolveAlias(trimmedChunk); + if (immediateToken) { + this.publishToken(immediateToken); + return; + } + } + + for (const char of chunk) { + if (char === '\u0003') { + this.dispatchAbort({ reason: 'sigint' }); + continue; + } + + if (char === '\u0004') { + this.dispatchAbort({ reason: 'eof' }); + continue; + } + + if (char === '\r' || char === '\n') { + this.emitResolvedToken(this.buffer); + continue; + } + + if (char === '\u007f') { + this.buffer = this.buffer.slice(0, -1); + this.repaintBuffer(); + continue; + } + + const immediateToken = this.resolveAlias(char); + if (immediateToken) { + this.publishToken(immediateToken); + continue; + } + + this.buffer += char; + this.repaintBuffer(); + } + } + + private emitResolvedToken(rawToken: string): void { + const token = normalizeRawToken(rawToken || this.buffer); + this.buffer = ''; + this.repaintBuffer(); + + if (!token) { + return; + } + + this.publishToken(this.resolveAlias(token) ?? token); + } + + private publishToken(token: string): void { + this.buffer = ''; + this.repaintBuffer(); + + for (const listener of this.listeners) { + listener(token); + } + } + + private repaintBuffer(): void { + if (!this.active) { + return; + } + + this.stdout.write(renderScreen(this.latestScreen, this.prompt, this.buffer)); + } + + private resolveAlias(rawToken: string): string | null { + const token = normalizeRawToken(rawToken); + if (!token) { + return null; + } + + const availableInputTokens = this.latestState?.availableInputTokens ?? []; + if (availableInputTokens.includes(token)) { + return token; + } + + if (/^\d+$/.test(token)) { + const switchToken = `switch:${token}`; + if (availableInputTokens.includes(switchToken)) { + return switchToken; + } + } + + if (/^[fF]$/.test(token) && availableInputTokens.includes('forfeit')) { + return 'forfeit'; + } + + return null; + } + + private dispatchAbort(event: PvpSessionTerminalStdioAbortEvent): void { + this.onAbort?.(event); + this.abortHandler?.(); + } +} + +export function createPvpSessionTerminalStdioAdapter( + options: CreatePvpSessionTerminalStdioAdapterOptions = {}, +): PvpSessionTerminalStdioAdapter { + return new PvpSessionTerminalStdioAdapter(options); +} diff --git a/src/pvp/turn-result-view.ts b/src/pvp/turn-result-view.ts new file mode 100644 index 00000000..cdbc0d2d --- /dev/null +++ b/src/pvp/turn-result-view.ts @@ -0,0 +1,401 @@ +import type { + BattleEndedPayload, + BattleSessionPhase, + BattleTurnResolvedPayload, + ProjectedBattleEvent, + ViewerVisibleState, + VisibleBenchPokemon, +} from '../server/battle/index.js'; +import type { PvpSessionClientState } from './session-client.js'; + +export interface PvpHealAppliedEvent { + eventType: 'heal_applied'; + target: 'self' | 'opponent'; + targetSlot: number; + targetSpeciesId: string; + hp: number; + hpMax: number; + heal: number; +} + +export type PvpTurnResultEvent = ProjectedBattleEvent | PvpHealAppliedEvent; + +export interface PvpTurnResolvedPayloadLike + extends Omit { + events: PvpTurnResultEvent[]; +} + +export interface PvpTurnResultLogEntry { + key: string; + eventType: PvpTurnResultEvent['eventType']; + title: string; + message: string; + emphasis: 'neutral' | 'positive' | 'negative' | 'terminal'; +} + +export interface PvpTurnResultBenchEntry { + slot: number; + label: string; + fainted: boolean; +} + +export interface PvpTurnResultSideSummary { + activeLabel: string | null; + benchLabel: string; + remainingBenchCount: number; + faintedBenchCount: number | null; + benchEntries: PvpTurnResultBenchEntry[]; +} + +export interface PvpTurnResultSummary { + self: PvpTurnResultSideSummary; + opponent: PvpTurnResultSideSummary; + nextPhase: BattleTurnResolvedPayload['nextPhase']; + nextPhaseLabel: string; + statusLabel: string; + terminalResultLabel: string | null; +} + +export interface PvpTurnResultView { + source: 'payload' | 'session'; + turn: number; + title: string; + eventCount: number; + hasEventLog: boolean; + logs: PvpTurnResultLogEntry[]; + summary: PvpTurnResultSummary; +} + +type PvpTurnResultSessionSlice = Pick; + +function formatPokemonName(pokemon: { speciesId: string; nickname?: string }): string { + return pokemon.nickname ? `${pokemon.nickname} (${pokemon.speciesId})` : pokemon.speciesId; +} + +function localizeStatus(status: string | null): string | null { + switch (status) { + case 'poison': + return '독'; + case 'badly_poisoned': + case 'toxic': + return '맹독'; + case 'burn': + return '화상'; + case 'paralysis': + return '마비'; + case 'sleep': + return '잠듦'; + case 'freeze': + return '얼음'; + default: + return status; + } +} + +function formatStatusLabel(status: string | null): string | null { + const localizedStatus = localizeStatus(status); + return localizedStatus ? `상태 ${localizedStatus}` : null; +} + +function formatActiveLabel(pokemon: ViewerVisibleState['self']['active'] | null | undefined): string | null { + if (!pokemon) { + return null; + } + + const parts = [ + formatPokemonName(pokemon), + `슬롯 ${pokemon.slot}`, + `Lv.${pokemon.levelEffective} (실레벨 ${pokemon.levelActual})`, + `HP ${pokemon.hp}/${pokemon.hpMax}`, + ]; + + const statusLabel = formatStatusLabel(pokemon.status); + if (statusLabel) { + parts.push(statusLabel); + } + + if (pokemon.fainted) { + parts.push('기절'); + } + + return parts.join(' · '); +} + +function formatBenchEntryLabel(pokemon: VisibleBenchPokemon): string { + return `${formatPokemonName(pokemon)} · 슬롯 ${pokemon.slot} · ${pokemon.fainted ? '기절' : '대기'}`; +} + +function formatSelfBenchLabel(bench: VisibleBenchPokemon[]): string { + const remaining = bench.filter((pokemon) => !pokemon.fainted).length; + const fainted = bench.length - remaining; + + if (bench.length === 0) { + return '벤치 없음'; + } + + return `벤치 ${bench.length}마리 · 출전 가능 ${remaining} · 기절 ${fainted}`; +} + +function formatOpponentBenchLabel(benchCount: number): string { + if (benchCount === 0) { + return '상대 벤치 없음'; + } + + return `상대 벤치 ${benchCount}칸 비공개`; +} + +function sideLabel(side: 'self' | 'opponent'): string { + return side === 'self' ? '내' : '상대'; +} + +function resolveNextPhaseLabel(nextPhase: BattleTurnResolvedPayload['nextPhase']): string { + switch (nextPhase) { + case 'awaiting_actions': + return '다음 행동 선택'; + case 'awaiting_replacement': + return '교체 포켓몬 선택'; + case 'finished': + return '배틀 종료'; + } +} + +function resolveStatusLabel(nextPhase: BattleTurnResolvedPayload['nextPhase']): string { + switch (nextPhase) { + case 'awaiting_actions': + return '다음 턴 진행 가능'; + case 'awaiting_replacement': + return '교체 필요'; + case 'finished': + return '전투 종료'; + } +} + +function resolveTerminalResultLabel(terminalResult: BattleEndedPayload | null | undefined): string | null { + if (!terminalResult) { + return null; + } + + const resultLabel = terminalResult.result === 'win' ? '승리' : '패배'; + return `${resultLabel} · 종료 사유 ${terminalResult.reason}`; +} + +function createSummary( + visibleState: ViewerVisibleState, + nextPhase: BattleTurnResolvedPayload['nextPhase'], + terminalResult: BattleEndedPayload | null | undefined, +): PvpTurnResultSummary { + const selfBenchEntries = visibleState.self.bench.map((pokemon) => ({ + slot: pokemon.slot, + label: formatBenchEntryLabel(pokemon), + fainted: pokemon.fainted, + })); + const remainingSelfBenchCount = visibleState.self.bench.filter((pokemon) => !pokemon.fainted).length; + const faintedSelfBenchCount = visibleState.self.bench.length - remainingSelfBenchCount; + + return { + self: { + activeLabel: formatActiveLabel(visibleState.self.active), + benchLabel: formatSelfBenchLabel(visibleState.self.bench), + remainingBenchCount: remainingSelfBenchCount, + faintedBenchCount: faintedSelfBenchCount, + benchEntries: selfBenchEntries, + }, + opponent: { + activeLabel: formatActiveLabel(visibleState.opponent.active), + benchLabel: formatOpponentBenchLabel(visibleState.opponent.benchCount), + remainingBenchCount: visibleState.opponent.benchCount, + faintedBenchCount: null, + benchEntries: [], + }, + nextPhase, + nextPhaseLabel: resolveNextPhaseLabel(nextPhase), + statusLabel: resolveStatusLabel(nextPhase), + terminalResultLabel: resolveTerminalResultLabel(terminalResult), + }; +} + +function createMoveMessage(event: Extract): PvpTurnResultLogEntry { + return { + key: `move:${event.actor}:${event.actorSlot}:${event.moveSlot}:${event.moveId}`, + eventType: event.eventType, + title: '기술 사용', + message: `${sideLabel(event.actor)} ${event.actorSpeciesId} (슬롯 ${event.actorSlot})이(가) ${event.moveId} 사용`, + emphasis: 'neutral', + }; +} + +function createSwitchMessage(event: Extract): PvpTurnResultLogEntry { + const movement = event.fromSlot === null ? `슬롯 ${event.toSlot} 출전` : `슬롯 ${event.fromSlot} → ${event.toSlot} 교체`; + + return { + key: `switch:${event.actor}:${event.fromSlot ?? 'none'}:${event.toSlot}`, + eventType: event.eventType, + title: '교체', + message: `${sideLabel(event.actor)} ${event.speciesId} · ${movement}`, + emphasis: 'neutral', + }; +} + +function createDamageMessage(event: Extract): PvpTurnResultLogEntry { + return { + key: `damage:${event.target}:${event.targetSlot}:${event.damage}:${event.hp}`, + eventType: event.eventType, + title: '피해', + message: `${sideLabel(event.target)} ${event.targetSpeciesId} (슬롯 ${event.targetSlot}) HP ${event.hp}/${event.hpMax} (-${event.damage})${event.fainted ? ' · 기절' : ''}`, + emphasis: event.target === 'opponent' ? 'positive' : 'negative', + }; +} + +function createHealMessage(event: Extract): PvpTurnResultLogEntry { + return { + key: `heal:${event.target}:${event.targetSlot}:${event.heal}:${event.hp}`, + eventType: event.eventType, + title: '회복', + message: `${sideLabel(event.target)} ${event.targetSpeciesId} (슬롯 ${event.targetSlot}) HP ${event.hp}/${event.hpMax} (+${event.heal})`, + emphasis: event.target === 'self' ? 'positive' : 'negative', + }; +} + +function createStatusMessage(event: Extract): PvpTurnResultLogEntry { + const localizedStatus = localizeStatus(event.status) ?? event.status; + + return { + key: `status:${event.target}:${event.targetSlot}:${event.status}`, + eventType: event.eventType, + title: '상태이상', + message: `${sideLabel(event.target)} ${event.targetSpeciesId} (슬롯 ${event.targetSlot}) 상태이상 ${localizedStatus}`, + emphasis: event.target === 'opponent' ? 'positive' : 'negative', + }; +} + +function createFaintedMessage(event: Extract): PvpTurnResultLogEntry { + return { + key: `fainted:${event.target}:${event.targetSlot}`, + eventType: event.eventType, + title: '기절', + message: `${sideLabel(event.target)} ${event.targetSpeciesId} (슬롯 ${event.targetSlot}) 기절`, + emphasis: event.target === 'opponent' ? 'positive' : 'negative', + }; +} + +function createReplacementMessage( + event: Extract, +): PvpTurnResultLogEntry { + return { + key: `replacement:${event.actor}:${event.slot}`, + eventType: event.eventType, + title: '교체 선택', + message: `${sideLabel(event.actor)} ${event.speciesId} · 슬롯 ${event.slot} 선택`, + emphasis: 'neutral', + }; +} + +function createForfeitMessage(event: Extract): PvpTurnResultLogEntry { + return { + key: `forfeit:${event.actor}`, + eventType: event.eventType, + title: '항복', + message: `${sideLabel(event.actor)} 플레이어가 항복`, + emphasis: event.actor === 'opponent' ? 'positive' : 'terminal', + }; +} + +function createLogEntry(event: PvpTurnResultEvent): PvpTurnResultLogEntry { + switch (event.eventType) { + case 'move_used': + return createMoveMessage(event); + case 'switch_used': + return createSwitchMessage(event); + case 'damage_applied': + return createDamageMessage(event); + case 'heal_applied': + return createHealMessage(event); + case 'status_applied': + return createStatusMessage(event); + case 'pokemon_fainted': + return createFaintedMessage(event); + case 'replacement_selected': + return createReplacementMessage(event); + case 'forfeit': + return createForfeitMessage(event); + } +} + +function createView( + source: PvpTurnResultView['source'], + turn: number, + visibleState: ViewerVisibleState, + nextPhase: BattleTurnResolvedPayload['nextPhase'], + terminalResult: BattleEndedPayload | null | undefined, + events: PvpTurnResultEvent[], +): PvpTurnResultView { + const logs = events.map((event) => createLogEntry(event)); + + return { + source, + turn, + title: `턴 ${turn} 결과`, + eventCount: logs.length, + hasEventLog: logs.length > 0, + logs, + summary: createSummary(visibleState, nextPhase, terminalResult), + }; +} + +export function createPvpTurnResultViewFromPayload(payload: PvpTurnResolvedPayloadLike | null): PvpTurnResultView | null { + if (!payload) { + return null; + } + + return createView( + 'payload', + payload.turn, + payload.postTurnVisibleState, + payload.nextPhase, + null, + payload.events, + ); +} + +function resolveNextPhaseFromSession(phase: BattleSessionPhase | null): BattleTurnResolvedPayload['nextPhase'] | null { + if (phase === 'awaiting_actions' || phase === 'awaiting_replacement' || phase === 'finished') { + return phase; + } + + return null; +} + +export function createPvpTurnResultView( + state: PvpTurnResultSessionSlice | null, + payload: PvpTurnResolvedPayloadLike | null = null, +): PvpTurnResultView | null { + if (!state) { + return null; + } + + if (payload) { + const view = createPvpTurnResultViewFromPayload(payload); + if (!view) { + return null; + } + + return { + ...view, + summary: createSummary(payload.postTurnVisibleState, payload.nextPhase, state.session.terminalResult), + }; + } + + const nextPhase = resolveNextPhaseFromSession(state.session.battleStatus); + if (state.session.lastResolvedTurn === null || !state.session.visibleState || !nextPhase) { + return null; + } + + return createView( + 'session', + state.session.lastResolvedTurn, + state.session.visibleState, + nextPhase, + state.session.terminalResult, + [], + ); +} diff --git a/src/pvp/websocket-client.ts b/src/pvp/websocket-client.ts new file mode 100644 index 00000000..b900ba76 --- /dev/null +++ b/src/pvp/websocket-client.ts @@ -0,0 +1,412 @@ +import type { PvpWsErrorEnvelope } from '../server/ws/index.js'; +import { + applyPvpTransportEnvelope, + createPvpClientCommand, + createPvpClientState, + type PvpClientInboundEnvelope, + type PvpClientOutboundEnvelope, + type PvpClientState, +} from './client-protocol.js'; +import type { CreateBattleCommandEnvelopeOptions } from './session-store.js'; + +export type PvpWebSocketTransportStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed' | 'error'; + +export interface PvpWebSocketMessageEvent { + data: string; +} + +export interface PvpWebSocketCloseEvent { + code?: number; + reason?: string; + wasClean?: boolean; +} + +export interface PvpWebSocketErrorEvent { + message?: string; + error?: unknown; +} + +export interface PvpWebSocketLike { + onopen: (() => void) | null; + onmessage: ((event: PvpWebSocketMessageEvent) => void) | null; + onclose: ((event: PvpWebSocketCloseEvent) => void) | null; + onerror: ((event: PvpWebSocketErrorEvent) => void) | null; + send(data: string): void; + close(code?: number, reason?: string): void; +} + +export type CreatePvpWebSocket = (url: string) => PvpWebSocketLike; + +export interface CreatePvpWebSocketClientOptions { + serverUrl: string; + roomId: string; + token: string; + createSocket: CreatePvpWebSocket; + now?: () => Date; +} + +export interface PvpWebSocketCloseInfo { + code: number | null; + reason: string | null; + wasClean: boolean; + at: string | null; +} + +export interface PvpWebSocketClientState { + protocol: PvpClientState; + transportStatus: PvpWebSocketTransportStatus; + connectionUrl: string | null; + lastConnectedAt: string | null; + lastDisconnectedAt: string | null; + lastInboundRawMessage: string | null; + lastOutboundRawMessage: string | null; + lastTransportError: PvpWsErrorEnvelope | null; + lastClose: PvpWebSocketCloseInfo; + connectCount: number; +} + +export interface SendBattleCommandResult { + state: PvpWebSocketClientState; + envelope: PvpClientOutboundEnvelope; + serialized: string; +} + +export type PvpWebSocketStateListener = (state: PvpWebSocketClientState) => void; + +const DEFAULT_CLOSE_INFO: PvpWebSocketCloseInfo = { + code: null, + reason: null, + wasClean: false, + at: null, +}; + +function cloneTransportError(error: PvpWsErrorEnvelope | null): PvpWsErrorEnvelope | null { + if (!error) { + return null; + } + + return { + ...error, + details: error.details ? structuredClone(error.details) : undefined, + }; +} + +function cloneCloseInfo(closeInfo: PvpWebSocketCloseInfo): PvpWebSocketCloseInfo { + return { ...closeInfo }; +} + +function cloneState(state: PvpWebSocketClientState): PvpWebSocketClientState { + return { + ...state, + protocol: structuredClone(state.protocol), + lastTransportError: cloneTransportError(state.lastTransportError), + lastClose: cloneCloseInfo(state.lastClose), + }; +} + +function toIsoString(now: () => Date): string { + return now().toISOString(); +} + +function createTransportError( + now: () => Date, + code: string, + message: string, + retryable: boolean, + details?: Record, +): PvpWsErrorEnvelope { + return { + type: 'ws.error', + sentAt: toIsoString(now), + code, + message, + retryable, + details: details ? structuredClone(details) : undefined, + }; +} + +export function createPvpWebSocketUrl(serverUrl: string, roomId: string, token: string): string { + const url = new URL(serverUrl); + + if (url.protocol === 'http:') { + url.protocol = 'ws:'; + } else if (url.protocol === 'https:') { + url.protocol = 'wss:'; + } + + if (url.pathname === '' || url.pathname === '/') { + url.pathname = '/ws/pvp'; + } + + url.searchParams.set('roomId', roomId); + url.searchParams.set('token', token); + return url.toString(); +} + +function createInitialState(connectionUrl: string): PvpWebSocketClientState { + return { + protocol: createPvpClientState(), + transportStatus: 'idle', + connectionUrl, + lastConnectedAt: null, + lastDisconnectedAt: null, + lastInboundRawMessage: null, + lastOutboundRawMessage: null, + lastTransportError: null, + lastClose: cloneCloseInfo(DEFAULT_CLOSE_INFO), + connectCount: 0, + }; +} + +export class PvpWebSocketClient { + private readonly connectionUrl: string; + + private readonly createSocket: CreatePvpWebSocket; + + private readonly now: () => Date; + + private readonly listeners = new Set(); + + private socket: PvpWebSocketLike | null = null; + + private state: PvpWebSocketClientState; + + private manualDisconnect = false; + + constructor(options: CreatePvpWebSocketClientOptions) { + this.connectionUrl = createPvpWebSocketUrl(options.serverUrl, options.roomId, options.token); + this.createSocket = options.createSocket; + this.now = options.now ?? (() => new Date()); + this.state = createInitialState(this.connectionUrl); + } + + getState(): PvpWebSocketClientState { + return cloneState(this.state); + } + + subscribe(listener: PvpWebSocketStateListener): () => void { + this.listeners.add(listener); + listener(this.getState()); + + return () => { + this.listeners.delete(listener); + }; + } + + connect(): PvpWebSocketClientState { + return this.openSocket('connecting'); + } + + reconnect(): PvpWebSocketClientState { + return this.openSocket('reconnecting'); + } + + disconnect(closeInfo: { code?: number; reason?: string } = {}): PvpWebSocketClientState { + this.manualDisconnect = true; + + const socket = this.socket; + if (!socket) { + this.patchState({ + transportStatus: 'closed', + lastDisconnectedAt: toIsoString(this.now), + lastClose: { + code: closeInfo.code ?? 1000, + reason: closeInfo.reason ?? 'client_disconnect_without_socket', + wasClean: true, + at: toIsoString(this.now), + }, + }); + return this.getState(); + } + + socket.close(closeInfo.code, closeInfo.reason); + return this.getState(); + } + + sendBattleCommand(options: CreateBattleCommandEnvelopeOptions): SendBattleCommandResult { + const socket = this.requireOpenSocket(); + const created = createPvpClientCommand(this.state.protocol, options); + const serialized = this.serializeEnvelope(created.envelope); + + socket.send(serialized); + this.patchState({ + protocol: created.state, + lastOutboundRawMessage: serialized, + lastTransportError: null, + }); + + return { + state: this.getState(), + envelope: created.envelope, + serialized, + }; + } + + private openSocket(status: Extract): PvpWebSocketClientState { + if (this.socket) { + throw new Error('PVP_CLIENT_SOCKET_ALREADY_OPEN'); + } + + this.manualDisconnect = false; + const socket = this.createSocket(this.connectionUrl); + this.socket = socket; + this.bindSocket(socket); + + this.patchState({ + transportStatus: status, + connectionUrl: this.connectionUrl, + connectCount: this.state.connectCount + 1, + lastTransportError: null, + }); + + return this.getState(); + } + + private bindSocket(socket: PvpWebSocketLike): void { + socket.onopen = () => { + this.patchState({ + transportStatus: 'connected', + lastConnectedAt: toIsoString(this.now), + lastTransportError: null, + }); + }; + + socket.onmessage = (event) => { + this.handleRawMessage(event.data); + }; + + socket.onerror = (event) => { + this.recordTransportError( + createTransportError( + this.now, + 'PVP_CLIENT_SOCKET_ERROR', + event.message ?? 'websocket transport error', + true, + event.error ? { error: String(event.error) } : undefined, + ), + 'error', + ); + }; + + socket.onclose = (event) => { + this.socket = null; + const closedAt = toIsoString(this.now); + this.patchState({ + transportStatus: 'closed', + lastDisconnectedAt: closedAt, + lastClose: { + code: event.code ?? (this.manualDisconnect ? 1000 : null), + reason: event.reason ?? (this.manualDisconnect ? 'client_disconnect' : null), + wasClean: event.wasClean ?? this.manualDisconnect, + at: closedAt, + }, + }); + this.manualDisconnect = false; + }; + } + + private handleRawMessage(raw: string): void { + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch (error) { + this.patchState({ + lastInboundRawMessage: raw, + }); + this.recordTransportError( + createTransportError(this.now, 'PVP_CLIENT_MESSAGE_PARSE_ERROR', 'Failed to parse websocket message.', true, { + raw, + cause: error instanceof Error ? error.message : String(error), + }), + 'error', + ); + return; + } + + let applied; + try { + applied = applyPvpTransportEnvelope(this.state.protocol, parsed as PvpClientInboundEnvelope, { + pongSentAt: toIsoString(this.now), + }); + } catch (error) { + this.patchState({ + lastInboundRawMessage: raw, + }); + this.recordTransportError( + createTransportError(this.now, 'PVP_CLIENT_MESSAGE_INVALID', 'Received invalid websocket envelope.', true, { + raw, + cause: error instanceof Error ? error.message : String(error), + }), + 'error', + ); + return; + } + + this.patchState({ + protocol: applied.state, + lastInboundRawMessage: raw, + lastTransportError: null, + transportStatus: this.state.transportStatus === 'error' ? 'connected' : this.state.transportStatus, + }); + + for (const envelope of applied.outbound) { + this.sendProtocolEnvelope(envelope); + } + } + + private sendProtocolEnvelope(envelope: PvpClientOutboundEnvelope): void { + const socket = this.requireOpenSocket(); + const serialized = this.serializeEnvelope(envelope); + socket.send(serialized); + this.patchState({ + lastOutboundRawMessage: serialized, + lastTransportError: null, + }); + } + + private serializeEnvelope(envelope: PvpClientOutboundEnvelope): string { + return JSON.stringify(envelope); + } + + private requireOpenSocket(): PvpWebSocketLike { + if (!this.socket) { + throw new Error('PVP_CLIENT_SOCKET_NOT_CONNECTED'); + } + + return this.socket; + } + + private recordTransportError(error: PvpWsErrorEnvelope, transportStatus: Extract): void { + this.patchState({ + transportStatus, + lastTransportError: error, + }); + } + + private patchState(patch: Partial): void { + const nextState: PvpWebSocketClientState = { + ...this.state, + ...patch, + protocol: patch.protocol ? structuredClone(patch.protocol) : this.state.protocol, + lastTransportError: patch.lastTransportError !== undefined + ? cloneTransportError(patch.lastTransportError) + : this.state.lastTransportError, + lastClose: patch.lastClose ? cloneCloseInfo(patch.lastClose) : this.state.lastClose, + }; + + this.state = nextState; + this.emit(); + } + + private emit(): void { + const snapshot = this.getState(); + for (const listener of this.listeners) { + listener(snapshot); + } + } +} + +export function createPvpWebSocketClient(options: CreatePvpWebSocketClientOptions): PvpWebSocketClient { + return new PvpWebSocketClient(options); +} diff --git a/src/server/battle/battle-command-service.ts b/src/server/battle/battle-command-service.ts new file mode 100644 index 00000000..f8e32e9a --- /dev/null +++ b/src/server/battle/battle-command-service.ts @@ -0,0 +1,204 @@ +import type { RoomSeat } from '../rooms/index.js'; +import { + collectAliveBenchSlots, + getActiveBattlePokemon, +} from './battle-engine-adapter.js'; +import type { + BattleCommandEnvelope, + BattleCommandRejectionCode, + BattleCommandSource, + BattleSessionRecord, +} from './battle-types.js'; +import { BATTLE_COMMAND_REJECTION_CODES } from './battle-types.js'; + +export type BattleCommandValidationResult = + | { ok: true } + | { + ok: false; + code: BattleCommandRejectionCode; + message: string; + retryable: boolean; + }; + +function reject( + code: BattleCommandRejectionCode, + message: string, + retryable: boolean, +): BattleCommandValidationResult { + return { + ok: false, + code, + message, + retryable, + }; +} + +export function getAvailableMoveSlots(session: BattleSessionRecord, seat: RoomSeat): number[] { + if (session.phase !== 'awaiting_actions') { + return []; + } + + const active = getActiveBattlePokemon(session, seat); + if (active.fainted) { + return []; + } + + return active.moves + .map((move, index) => ({ move, slot: index + 1 })) + .filter(({ move }) => move.currentPp > 0) + .map(({ slot }) => slot); +} + +export function getAvailableSwitchSlots(session: BattleSessionRecord, seat: RoomSeat): number[] { + return collectAliveBenchSlots(session, seat); +} + +export function validateBattleCommand(args: { + session: BattleSessionRecord; + seat: RoomSeat; + envelope: BattleCommandEnvelope; + now: Date; + source: BattleCommandSource; +}): BattleCommandValidationResult { + const { session, seat, envelope, now, source } = args; + const { battleId, roomId } = envelope; + const { clientCommandId, phase, turn, command } = envelope.payload; + + if (roomId !== session.roomId || battleId !== session.battleId) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_BATTLE_MISMATCH, + 'The submitted battle command does not target this battle session.', + false, + ); + } + + if (clientCommandId.trim().length === 0) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_CLIENT_ID_REQUIRED, + 'clientCommandId is required for PvP battle commands.', + false, + ); + } + + if (session.phase === 'finished' || session.phase === 'abandoned') { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_BATTLE_ALREADY_FINISHED, + 'This PvP battle has already finished.', + false, + ); + } + + if (session.seenClientCommandIds.includes(clientCommandId)) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_DUPLICATE, + 'This clientCommandId was already accepted for the battle.', + false, + ); + } + + if ( + source !== 'timeout_auto' + && session.requestState + && session.requestState.requiredSeats.includes(seat) + && now.getTime() > new Date(session.requestState.deadlineAt).getTime() + ) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_TIMEOUT, + 'The current battle request deadline has already elapsed.', + false, + ); + } + + if (phase !== session.phase) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_PHASE_MISMATCH, + 'The submitted battle command phase does not match the server phase.', + true, + ); + } + + if (turn !== session.turn) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_TURN_MISMATCH, + 'The submitted battle command turn does not match the server turn.', + true, + ); + } + + if (command.type === 'forfeit') { + return { ok: true }; + } + + if (session.phase === 'awaiting_actions') { + if (session.pendingCommands[seat]) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_DUPLICATE, + 'A battle command for this seat and turn was already accepted.', + true, + ); + } + + if (command.type === 'choose_replacement') { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_PHASE_MISMATCH, + 'Replacement commands are only accepted during awaiting_replacement.', + true, + ); + } + + if (command.type === 'choose_move') { + if (!getAvailableMoveSlots(session, seat).includes(command.moveSlot)) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_MOVE_INVALID, + 'The requested move slot is not currently available.', + true, + ); + } + return { ok: true }; + } + + if (!getAvailableSwitchSlots(session, seat).includes(command.targetSlot)) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_SWITCH_INVALID, + 'The requested switch target is not currently available.', + true, + ); + } + + return { ok: true }; + } + + if (command.type !== 'choose_replacement') { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_PHASE_MISMATCH, + 'Only replacement commands are accepted during awaiting_replacement.', + true, + ); + } + + if (!session.pendingReplacementSeats.includes(seat)) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_PHASE_MISMATCH, + 'This seat is not currently required to choose a replacement.', + true, + ); + } + + if (session.pendingReplacementCommands[seat]) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_DUPLICATE, + 'A replacement command for this seat and turn was already accepted.', + true, + ); + } + + if (!getAvailableSwitchSlots(session, seat).includes(command.targetSlot)) { + return reject( + BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_REPLACEMENT_INVALID, + 'The requested replacement target is not currently available.', + true, + ); + } + + return { ok: true }; +} diff --git a/src/server/battle/battle-engine-adapter.ts b/src/server/battle/battle-engine-adapter.ts new file mode 100644 index 00000000..58d3b1e4 --- /dev/null +++ b/src/server/battle/battle-engine-adapter.ts @@ -0,0 +1,175 @@ +import { + createBattlePokemon, + createBattleState, + getActivePokemon, + hasAlivePokemon, + resolveTurn, +} from '../../core/turn-battle.js'; +import type { BattleState, BattleTeam, TurnAction } from '../../core/types.js'; +import type { ActivePartySnapshot } from '../parties/index.js'; +import type { RoomSeat } from '../rooms/index.js'; +import type { + BattleCommand, + BattleDataResolver, + BattlePokemonRuntimeMetadata, + BattleSeatRuntimeState, + BattleSessionRecord, +} from './battle-types.js'; + +export function buildSeatRuntimeState(party: ActivePartySnapshot): BattleSeatRuntimeState { + return { + userId: party.playerId, + partySnapshotId: party.snapshotId, + partySnapshotVersion: party.snapshotVersion, + members: party.members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId, + speciesId: member.speciesId, + nickname: member.nickname, + levelActual: member.levelActual, + levelEffective: member.levelEffective, + moveIds: [...member.moves], + })), + }; +} + +export function createAuthoritativeBattleState(args: { + generation: ActivePartySnapshot['generation']; + hostParty: ActivePartySnapshot; + guestParty: ActivePartySnapshot; + dataResolver: BattleDataResolver; +}): BattleState { + const { generation, hostParty, guestParty, dataResolver } = args; + + const hostTeam = hostParty.members.map((member) => createRuntimeBattlePokemon(generation, member, dataResolver)); + const guestTeam = guestParty.members.map((member) => createRuntimeBattlePokemon(generation, member, dataResolver)); + + return createBattleState(hostTeam, guestTeam); +} + +function createRuntimeBattlePokemon( + generation: ActivePartySnapshot['generation'], + member: ActivePartySnapshot['members'][number], + dataResolver: BattleDataResolver, +) { + const species = dataResolver.resolveSpecies(generation, member.speciesId); + if (!species) { + throw new Error(`Unknown species for battle session: ${member.speciesId}`); + } + + const moves = member.moves.map((moveId) => { + const move = dataResolver.resolveMove(generation, moveId); + if (!move) { + throw new Error(`Unknown move for battle session: ${moveId}`); + } + return move; + }); + + return createBattlePokemon( + { + id: species.id, + types: species.types, + level: member.levelEffective, + baseStats: species.base_stats, + displayName: member.nickname ?? species.name, + }, + moves, + ); +} + +export function seatToEngineSide(seat: RoomSeat): 'player' | 'opponent' { + return seat === 'host' ? 'player' : 'opponent'; +} + +export function getSeatTeam(session: BattleSessionRecord, seat: RoomSeat): BattleTeam { + return session.battleState[seatToEngineSide(seat)]; +} + +export function getSeatRuntimeMember( + session: BattleSessionRecord, + seat: RoomSeat, + slot: number, +): BattlePokemonRuntimeMetadata | undefined { + return session.seatState[seat].members.find((member) => member.slot === slot); +} + +export function getSeatRuntimeMemberByIndex( + session: BattleSessionRecord, + seat: RoomSeat, + index: number, +): BattlePokemonRuntimeMetadata | undefined { + return session.seatState[seat].members[index]; +} + +export function getActiveRuntimeMember( + session: BattleSessionRecord, + seat: RoomSeat, +): BattlePokemonRuntimeMetadata | undefined { + const team = getSeatTeam(session, seat); + return getSeatRuntimeMemberByIndex(session, seat, team.activeIndex); +} + +export function getActiveSlot(session: BattleSessionRecord, seat: RoomSeat): number | null { + return getActiveRuntimeMember(session, seat)?.slot ?? null; +} + +export function hasAliveSeatPokemon(session: BattleSessionRecord, seat: RoomSeat): boolean { + return hasAlivePokemon(getSeatTeam(session, seat)); +} + +export function getRemainingCount(session: BattleSessionRecord, seat: RoomSeat): number { + return getSeatTeam(session, seat).pokemon.filter((pokemon) => !pokemon.fainted).length; +} + +export function findPokemonIndexBySlot(session: BattleSessionRecord, seat: RoomSeat, slot: number): number { + return session.seatState[seat].members.findIndex((member) => member.slot === slot); +} + +export function toEngineAction( + session: BattleSessionRecord, + seat: RoomSeat, + command: BattleCommand, +): TurnAction { + switch (command.type) { + case 'choose_move': + return { type: 'move', moveIndex: command.moveSlot - 1 }; + case 'choose_switch': { + const pokemonIndex = findPokemonIndexBySlot(session, seat, command.targetSlot); + return { type: 'switch', pokemonIndex }; + } + case 'forfeit': + return { type: 'surrender' }; + case 'choose_replacement': + throw new Error('Replacement commands are resolved outside the battle engine.'); + } +} + +export function resolveAuthoritativeTurn( + state: BattleState, + hostAction: TurnAction, + guestAction: TurnAction, +) { + return resolveTurn(state, hostAction, guestAction); +} + +export function collectAliveBenchSlots(session: BattleSessionRecord, seat: RoomSeat): number[] { + const team = getSeatTeam(session, seat); + return team.pokemon + .map((pokemon, index) => ({ pokemon, index })) + .filter(({ pokemon, index }) => index !== team.activeIndex && !pokemon.fainted) + .map(({ index }) => session.seatState[seat].members[index]?.slot) + .filter((slot): slot is number => typeof slot === 'number'); +} + +export function applyReplacementBySlot(session: BattleSessionRecord, seat: RoomSeat, slot: number): void { + const team = getSeatTeam(session, seat); + const pokemonIndex = findPokemonIndexBySlot(session, seat, slot); + if (pokemonIndex < 0) { + throw new Error(`Unknown replacement slot ${slot} for ${seat}`); + } + team.activeIndex = pokemonIndex; +} + +export function getActiveBattlePokemon(session: BattleSessionRecord, seat: RoomSeat) { + return getActivePokemon(getSeatTeam(session, seat)); +} diff --git a/src/server/battle/battle-event-log.ts b/src/server/battle/battle-event-log.ts new file mode 100644 index 00000000..4050df50 --- /dev/null +++ b/src/server/battle/battle-event-log.ts @@ -0,0 +1,391 @@ +import type { BattlePokemon, BattleState } from '../../core/types.js'; +import type { RoomSeat } from '../rooms/index.js'; +import { + collectAliveBenchSlots, + getActiveBattlePokemon, + getActiveSlot, + getSeatRuntimeMemberByIndex, + getSeatTeam, +} from './battle-engine-adapter.js'; +import type { + BattleActionRequestPayload, + BattleCommand, + BattleForfeitEvent, + BattleLoggedEvent, + BattleReplacementRequestPayload, + BattleSessionRecord, + ProjectedBattleEvent, + ViewerRelativeSide, + ViewerVisibleState, + VisibleActivePokemon, + VisibleBenchPokemon, + VisibleMoveOption, +} from './battle-types.js'; + +function opponentSeat(seat: RoomSeat): RoomSeat { + return seat === 'host' ? 'guest' : 'host'; +} + +function toViewerSide(viewerSeat: RoomSeat, seat: RoomSeat): ViewerRelativeSide { + return viewerSeat === seat ? 'self' : 'opponent'; +} + +function toVisibleMove(slot: number, moveId: string, currentPp: number): VisibleMoveOption { + return { + slot, + id: moveId, + disabled: currentPp <= 0, + currentPp, + }; +} + +function toVisibleBenchPokemon(session: BattleSessionRecord, seat: RoomSeat, index: number): VisibleBenchPokemon { + const runtime = getSeatRuntimeMemberByIndex(session, seat, index); + const pokemon = getSeatTeam(session, seat).pokemon[index]; + if (!runtime || !pokemon) { + throw new Error(`Missing bench member metadata for ${seat} index ${index}`); + } + + return { + slot: runtime.slot, + speciesId: runtime.speciesId, + nickname: runtime.nickname, + fainted: pokemon.fainted, + }; +} + +function toVisibleActivePokemon( + session: BattleSessionRecord, + seat: RoomSeat, + includeMoves: boolean, +): VisibleActivePokemon { + const team = getSeatTeam(session, seat); + const runtime = getSeatRuntimeMemberByIndex(session, seat, team.activeIndex); + const pokemon = getActiveBattlePokemon(session, seat); + if (!runtime || !pokemon) { + throw new Error(`Missing active Pokémon metadata for ${seat}`); + } + + const visibleActive: VisibleActivePokemon = { + slot: runtime.slot, + speciesId: runtime.speciesId, + nickname: runtime.nickname, + levelActual: runtime.levelActual, + levelEffective: runtime.levelEffective, + hp: pokemon.currentHp, + hpMax: pokemon.maxHp, + status: pokemon.statusCondition, + fainted: pokemon.fainted, + }; + + if (includeMoves) { + visibleActive.moves = runtime.moveIds.map((moveId, index) => + toVisibleMove(index + 1, moveId, pokemon.moves[index]?.currentPp ?? 0), + ); + } + + return visibleActive; +} + +export function createViewerVisibleState(session: BattleSessionRecord, viewerSeat: RoomSeat): ViewerVisibleState { + const enemySeat = opponentSeat(viewerSeat); + const selfTeam = getSeatTeam(session, viewerSeat); + const opponentTeam = getSeatTeam(session, enemySeat); + + return { + self: { + active: toVisibleActivePokemon(session, viewerSeat, true), + bench: selfTeam.pokemon + .map((_, index) => index) + .filter((index) => index !== selfTeam.activeIndex) + .map((index) => toVisibleBenchPokemon(session, viewerSeat, index)), + }, + opponent: { + active: toVisibleActivePokemon(session, enemySeat, false), + benchCount: opponentTeam.pokemon.filter((_, index) => index !== opponentTeam.activeIndex).length, + }, + }; +} + +export function createActionRequestPayload( + session: BattleSessionRecord, + seat: RoomSeat, +): BattleActionRequestPayload { + const active = toVisibleActivePokemon(session, seat, true); + const deadlineMs = session.rulesetSnapshot.battlePolicy.actionTimeoutSeconds * 1000; + const availableMoves = active.moves ?? []; + const availableSwitches = collectAliveBenchSlots(session, seat).map((slot) => { + const index = session.seatState[seat].members.findIndex((member) => member.slot === slot); + return toVisibleBenchPokemon(session, seat, index); + }); + + return { + turn: session.turn, + phase: 'awaiting_actions', + requestId: `battle:${session.battleId}:turn:${session.turn}:seat:${seat}:action`, + deadlineMs, + request: { + kind: 'choose_move_or_switch', + activePokemon: active, + availableMoves, + availableSwitches, + }, + }; +} + +export function createReplacementRequestPayload( + session: BattleSessionRecord, + seat: RoomSeat, +): BattleReplacementRequestPayload { + const deadlineMs = session.rulesetSnapshot.battlePolicy.actionTimeoutSeconds * 1000; + const faintedSlot = getActiveSlot(session, seat); + const availableReplacements = collectAliveBenchSlots(session, seat).map((slot) => { + const index = session.seatState[seat].members.findIndex((member) => member.slot === slot); + return toVisibleBenchPokemon(session, seat, index); + }); + + return { + turn: session.turn, + phase: 'awaiting_replacement', + requestId: `battle:${session.battleId}:turn:${session.turn}:seat:${seat}:replacement`, + deadlineMs, + faintedSlot, + availableReplacements, + }; +} + +function pushMoveEvent( + events: BattleLoggedEvent[], + session: BattleSessionRecord, + seat: RoomSeat, + command: Extract, + beforeState: BattleState, +): void { + const team = seat === 'host' ? beforeState.player : beforeState.opponent; + const runtime = getSeatRuntimeMemberByIndex(session, seat, team.activeIndex); + if (!runtime) { + return; + } + + events.push({ + eventType: 'move_used', + actorSeat: seat, + actorSlot: runtime.slot, + actorSpeciesId: runtime.speciesId, + moveSlot: command.moveSlot, + moveId: runtime.moveIds[command.moveSlot - 1] ?? `slot-${command.moveSlot}`, + }); +} + +function pushSwitchEvent( + events: BattleLoggedEvent[], + session: BattleSessionRecord, + seat: RoomSeat, + command: Extract, + beforeState: BattleState, +): void { + const team = seat === 'host' ? beforeState.player : beforeState.opponent; + const fromRuntime = getSeatRuntimeMemberByIndex(session, seat, team.activeIndex); + const toRuntime = session.seatState[seat].members.find((member) => member.slot === command.targetSlot); + if (!toRuntime) { + return; + } + + events.push({ + eventType: 'switch_used', + actorSeat: seat, + fromSlot: fromRuntime?.slot ?? null, + toSlot: toRuntime.slot, + speciesId: toRuntime.speciesId, + }); +} + +function pushStateDiffEvents( + events: BattleLoggedEvent[], + session: BattleSessionRecord, + seat: RoomSeat, + beforePokemon: BattlePokemon, + afterPokemon: BattlePokemon, + index: number, +): void { + const runtime = getSeatRuntimeMemberByIndex(session, seat, index); + if (!runtime) { + return; + } + + const damage = Math.max(0, beforePokemon.currentHp - afterPokemon.currentHp); + if (damage > 0) { + events.push({ + eventType: 'damage_applied', + targetSeat: seat, + targetSlot: runtime.slot, + targetSpeciesId: runtime.speciesId, + hp: afterPokemon.currentHp, + hpMax: afterPokemon.maxHp, + damage, + fainted: afterPokemon.fainted, + }); + } + + if (beforePokemon.statusCondition !== afterPokemon.statusCondition && afterPokemon.statusCondition) { + events.push({ + eventType: 'status_applied', + targetSeat: seat, + targetSlot: runtime.slot, + targetSpeciesId: runtime.speciesId, + status: afterPokemon.statusCondition, + }); + } + + if (!beforePokemon.fainted && afterPokemon.fainted) { + events.push({ + eventType: 'pokemon_fainted', + targetSeat: seat, + targetSlot: runtime.slot, + targetSpeciesId: runtime.speciesId, + }); + } +} + +export function createLoggedEventsFromTurn(args: { + session: BattleSessionRecord; + beforeState: BattleState; + commands: Record; +}): BattleLoggedEvent[] { + const { session, beforeState, commands } = args; + const events: BattleLoggedEvent[] = []; + + for (const seat of ['host', 'guest'] as const) { + const command = commands[seat]; + if (command?.type === 'choose_switch') { + pushSwitchEvent(events, session, seat, command, beforeState); + } + } + + for (const seat of ['host', 'guest'] as const) { + const command = commands[seat]; + if (command?.type === 'choose_move') { + pushMoveEvent(events, session, seat, command, beforeState); + } + } + + const sides: Array<[RoomSeat, BattleState['player']]> = [ + ['host', beforeState.player], + ['guest', beforeState.opponent], + ]; + + for (const [seat, beforeTeam] of sides) { + const afterTeam = getSeatTeam(session, seat); + beforeTeam.pokemon.forEach((beforePokemon, index) => { + const afterPokemon = afterTeam.pokemon[index]; + if (!afterPokemon) { + return; + } + pushStateDiffEvents(events, session, seat, beforePokemon, afterPokemon, index); + }); + } + + return events; +} + +export function createReplacementEvents( + session: BattleSessionRecord, + replacementSlots: Partial>, +): BattleLoggedEvent[] { + const events: BattleLoggedEvent[] = []; + + for (const seat of ['host', 'guest'] as const) { + const slot = replacementSlots[seat]; + if (typeof slot !== 'number') { + continue; + } + + const runtime = session.seatState[seat].members.find((member) => member.slot === slot); + if (!runtime) { + continue; + } + + events.push({ + eventType: 'replacement_selected', + actorSeat: seat, + slot, + speciesId: runtime.speciesId, + }); + } + + return events; +} + +export function createForfeitEvent(forfeitingSeat: RoomSeat): BattleForfeitEvent[] { + return [ + { + eventType: 'forfeit', + actorSeat: forfeitingSeat, + }, + ]; +} + +export function projectEventsForViewer( + viewerSeat: RoomSeat, + events: BattleLoggedEvent[], +): ProjectedBattleEvent[] { + return events.map((event): ProjectedBattleEvent => { + switch (event.eventType) { + case 'move_used': + return { + eventType: 'move_used', + actor: toViewerSide(viewerSeat, event.actorSeat), + actorSlot: event.actorSlot, + actorSpeciesId: event.actorSpeciesId, + moveSlot: event.moveSlot, + moveId: event.moveId, + }; + case 'switch_used': + return { + eventType: 'switch_used', + actor: toViewerSide(viewerSeat, event.actorSeat), + fromSlot: event.fromSlot, + toSlot: event.toSlot, + speciesId: event.speciesId, + }; + case 'damage_applied': + return { + eventType: 'damage_applied', + target: toViewerSide(viewerSeat, event.targetSeat), + targetSlot: event.targetSlot, + targetSpeciesId: event.targetSpeciesId, + hp: event.hp, + hpMax: event.hpMax, + damage: event.damage, + fainted: event.fainted, + }; + case 'status_applied': + return { + eventType: 'status_applied', + target: toViewerSide(viewerSeat, event.targetSeat), + targetSlot: event.targetSlot, + targetSpeciesId: event.targetSpeciesId, + status: event.status, + }; + case 'pokemon_fainted': + return { + eventType: 'pokemon_fainted', + target: toViewerSide(viewerSeat, event.targetSeat), + targetSlot: event.targetSlot, + targetSpeciesId: event.targetSpeciesId, + }; + case 'replacement_selected': + return { + eventType: 'replacement_selected', + actor: toViewerSide(viewerSeat, event.actorSeat), + slot: event.slot, + speciesId: event.speciesId, + }; + case 'forfeit': + return { + eventType: 'forfeit', + actor: toViewerSide(viewerSeat, event.actorSeat), + }; + } + }); +} diff --git a/src/server/battle/battle-session-service.ts b/src/server/battle/battle-session-service.ts new file mode 100644 index 00000000..dd71433b --- /dev/null +++ b/src/server/battle/battle-session-service.ts @@ -0,0 +1,489 @@ +import type { RoomSeat } from '../rooms/index.js'; +import { buildRoomSnapshotPayload } from '../projection/battle-projection.js'; +import { + buildSeatRuntimeState, + createAuthoritativeBattleState, +} from './battle-engine-adapter.js'; +import { validateBattleCommand } from './battle-command-service.js'; +import { + createActionRequestPayload, + createReplacementRequestPayload, + projectEventsForViewer, +} from './battle-event-log.js'; +import { + createEndedPayloadResult, + resolveForfeit, + resolveSubmittedActions, + resolveSubmittedReplacements, +} from './battle-turn-service.js'; +import type { + BattleCommandEnvelope, + BattleCommandPhase, + BattleCommandRejectionCode, + BattleCommandSource, + BattleDataResolver, + BattleFinishReason, + BattleLoggedEvent, + BattleRequestKind, + BattleRequestState, + BattleServerEventEnvelope, + BattleSessionCreateInput, + BattleSessionMutationResult, + BattleSessionRecord, + BattleSessionSubmitInput, + BattleSessionResult, +} from './battle-types.js'; + +export interface BattleSessionServiceOptions { + dataResolver: BattleDataResolver; + now?: () => Date; + battleIdGenerator?: () => string; +} + +const SEATS: RoomSeat[] = ['host', 'guest']; + +export class BattleSessionService { + private readonly dataResolver: BattleDataResolver; + private readonly now: () => Date; + private readonly battleIdGenerator: () => string; + + constructor(options: BattleSessionServiceOptions) { + this.dataResolver = options.dataResolver; + this.now = options.now ?? (() => new Date()); + this.battleIdGenerator = options.battleIdGenerator ?? (() => `battle_${crypto.randomUUID()}`); + } + + createSession(input: BattleSessionCreateInput): BattleSessionMutationResult { + const createdAt = this.now().toISOString(); + const roomSnapshot = { + ...input.room, + room: { + ...input.room.room, + status: 'in_progress' as const, + }, + }; + + const session: BattleSessionRecord = { + roomId: input.room.room.roomId, + battleId: this.battleIdGenerator(), + generation: input.room.room.generation, + rulesetKey: input.room.room.rulesetKey, + phase: 'awaiting_actions', + turn: 1, + roomStatus: 'in_progress', + rulesetSnapshot: input.room.rulesetSnapshot, + roomSnapshot, + battleState: createAuthoritativeBattleState({ + generation: input.room.room.generation, + hostParty: input.hostParty, + guestParty: input.guestParty, + dataResolver: this.dataResolver, + }), + seatState: { + host: buildSeatRuntimeState(input.hostParty), + guest: buildSeatRuntimeState(input.guestParty), + }, + pendingCommands: {}, + pendingReplacementSeats: [], + pendingReplacementCommands: {}, + requestState: null, + timeoutState: { + host: { consecutive: 0, total: 0, lastTimeoutAt: null }, + guest: { consecutive: 0, total: 0, lastTimeoutAt: null }, + }, + commandLog: [], + eventLog: [], + seenClientCommandIds: [], + nextSeq: 1, + result: null, + createdAt, + updatedAt: createdAt, + }; + + this.issueRequestState(session, { + kind: 'choose_move_or_switch', + phase: 'awaiting_actions', + turn: session.turn, + requiredSeats: SEATS, + issuedAt: createdAt, + }); + + const eventsBySeat = this.createEmptySeatEventMap(); + const sentAt = createdAt; + for (const seat of SEATS) { + this.pushEvent(session, eventsBySeat, seat, { + type: 'room.snapshot', + roomId: session.roomId, + battleId: session.battleId, + seq: this.nextSeq(session), + sentAt, + payload: buildRoomSnapshotPayload(session, seat, new Date(sentAt)), + }); + this.pushEvent(session, eventsBySeat, seat, { + type: 'battle.request_action', + roomId: session.roomId, + battleId: session.battleId, + seq: this.nextSeq(session), + sentAt, + payload: createActionRequestPayload(session, seat), + }); + } + session.updatedAt = sentAt; + + return { session, eventsBySeat }; + } + + submitCommand(input: BattleSessionSubmitInput): BattleSessionMutationResult { + const { session, seat, envelope } = input; + const source = input.source ?? 'client'; + const now = this.now(); + const eventsBySeat = this.createEmptySeatEventMap(); + const validation = validateBattleCommand({ session, seat, envelope, now, source }); + if (!validation.ok) { + const sentAt = now.toISOString(); + this.recordCommand(session, { + seat, + envelope, + source, + accepted: false, + code: validation.code, + recordedAt: sentAt, + }); + this.pushEvent(session, eventsBySeat, seat, { + type: 'battle.command_rejected', + roomId: session.roomId, + battleId: session.battleId, + seq: this.nextSeq(session), + sentAt, + payload: { + clientCommandId: envelope.payload.clientCommandId, + code: validation.code, + message: validation.message, + retryable: validation.retryable, + }, + }); + session.updatedAt = sentAt; + return { session, eventsBySeat }; + } + + session.seenClientCommandIds.push(envelope.payload.clientCommandId); + + const acceptedAt = now.toISOString(); + this.recordCommand(session, { + seat, + envelope, + source, + accepted: true, + code: null, + recordedAt: acceptedAt, + }); + if (source === 'client') { + session.timeoutState[seat].consecutive = 0; + } + + this.pushEvent(session, eventsBySeat, seat, { + type: 'battle.command_accepted', + roomId: session.roomId, + battleId: session.battleId, + seq: this.nextSeq(session), + sentAt: acceptedAt, + payload: { + clientCommandId: envelope.payload.clientCommandId, + turn: envelope.payload.turn, + phase: envelope.payload.phase, + lockedIn: true, + }, + }); + session.updatedAt = acceptedAt; + + if (envelope.payload.command.type === 'forfeit') { + const resolvedAt = this.now().toISOString(); + const resolution = resolveForfeit({ + session, + forfeitingSeat: seat, + recordedAt: resolvedAt, + }); + this.clearPendingRequestState(session); + this.emitResolutionEvents(eventsBySeat, session, resolution.events, resolution.nextPhase, resolvedAt); + const finishedAt = this.now().toISOString(); + if (session.result) { + session.result.recordedAt = finishedAt; + } + this.emitBattleEnded(eventsBySeat, session, finishedAt); + session.updatedAt = finishedAt; + return { session, eventsBySeat }; + } + + if (session.phase === 'awaiting_actions') { + session.pendingCommands[seat] = envelope.payload; + if (session.pendingCommands.host && session.pendingCommands.guest) { + const resolvedAt = this.now().toISOString(); + const resolution = resolveSubmittedActions({ + session, + commands: { + host: session.pendingCommands.host.command, + guest: session.pendingCommands.guest.command, + }, + recordedAt: resolvedAt, + }); + session.pendingCommands = {}; + this.transitionRequestState(session, resolution.nextPhase, resolvedAt); + this.emitResolutionEvents(eventsBySeat, session, resolution.events, resolution.nextPhase, resolvedAt); + if (resolution.nextPhase === 'awaiting_actions') { + this.emitActionRequests(eventsBySeat, session, resolvedAt); + } else if (resolution.nextPhase === 'awaiting_replacement') { + this.emitReplacementRequests(eventsBySeat, session, resolvedAt); + } else { + this.emitBattleEnded(eventsBySeat, session, resolvedAt); + } + session.updatedAt = resolvedAt; + } + return { session, eventsBySeat }; + } + + session.pendingReplacementCommands[seat] = envelope.payload; + const readyToResolve = session.pendingReplacementSeats.every( + (requiredSeat) => session.pendingReplacementCommands[requiredSeat]?.command.type === 'choose_replacement', + ); + + if (readyToResolve) { + const resolvedAt = this.now().toISOString(); + const replacementSlots: Partial> = {}; + for (const requiredSeat of session.pendingReplacementSeats) { + const payload = session.pendingReplacementCommands[requiredSeat]; + if (payload?.command.type === 'choose_replacement') { + replacementSlots[requiredSeat] = payload.command.targetSlot; + } + } + const resolution = resolveSubmittedReplacements({ + session, + replacementSlots, + recordedAt: resolvedAt, + }); + session.pendingReplacementCommands = {}; + this.transitionRequestState(session, resolution.nextPhase, resolvedAt); + this.emitResolutionEvents(eventsBySeat, session, resolution.events, resolution.nextPhase, resolvedAt); + if (resolution.nextPhase === 'awaiting_actions') { + this.emitActionRequests(eventsBySeat, session, resolvedAt); + } else { + this.emitBattleEnded(eventsBySeat, session, resolvedAt); + } + session.updatedAt = resolvedAt; + } + + return { session, eventsBySeat }; + } + + submitTimeoutForfeit(args: { session: BattleSessionRecord; loserSeat: RoomSeat }): BattleSessionMutationResult { + const { session, loserSeat } = args; + const finishedAt = this.now().toISOString(); + const winnerSeat: RoomSeat = loserSeat === 'host' ? 'guest' : 'host'; + session.phase = 'finished'; + session.result = { + winnerSeat, + loserSeat, + reason: 'timeout_forfeit', + recordedAt: finishedAt, + }; + this.clearPendingRequestState(session); + + const eventsBySeat = this.createEmptySeatEventMap(); + this.emitBattleEnded(eventsBySeat, session, finishedAt); + session.updatedAt = finishedAt; + return { session, eventsBySeat }; + } + + private issueRequestState( + session: BattleSessionRecord, + args: { + kind: BattleRequestKind; + phase: BattleCommandPhase; + turn: number; + requiredSeats: RoomSeat[]; + issuedAt: string; + }, + ): void { + const { kind, phase, turn, requiredSeats, issuedAt } = args; + session.requestState = { + kind, + phase, + turn, + issuedAt, + deadlineAt: new Date(new Date(issuedAt).getTime() + this.getRequestTimeoutMs(session)).toISOString(), + requiredSeats: [...requiredSeats], + }; + } + + private transitionRequestState( + session: BattleSessionRecord, + nextPhase: 'awaiting_actions' | 'awaiting_replacement' | 'finished', + issuedAt: string, + ): void { + if (nextPhase === 'awaiting_actions') { + this.issueRequestState(session, { + kind: 'choose_move_or_switch', + phase: 'awaiting_actions', + turn: session.turn, + requiredSeats: SEATS, + issuedAt, + }); + return; + } + + if (nextPhase === 'awaiting_replacement') { + this.issueRequestState(session, { + kind: 'choose_replacement', + phase: 'awaiting_replacement', + turn: session.turn, + requiredSeats: [...session.pendingReplacementSeats], + issuedAt, + }); + return; + } + + this.clearPendingRequestState(session); + } + + private clearPendingRequestState(session: BattleSessionRecord): void { + session.pendingCommands = {}; + session.pendingReplacementCommands = {}; + session.pendingReplacementSeats = []; + session.requestState = null; + } + + private getRequestTimeoutMs(session: BattleSessionRecord): number { + return session.rulesetSnapshot.battlePolicy.actionTimeoutSeconds * 1000; + } + + private recordCommand( + session: BattleSessionRecord, + args: { + seat: RoomSeat; + envelope: BattleCommandEnvelope; + source: BattleCommandSource; + accepted: boolean; + code: BattleCommandRejectionCode | null; + recordedAt: string; + }, + ): void { + const { seat, envelope, source, accepted, code, recordedAt } = args; + session.commandLog.push({ + clientCommandId: envelope.payload.clientCommandId, + seat, + turn: envelope.payload.turn, + phase: envelope.payload.phase, + command: structuredClone(envelope.payload.command), + source, + accepted, + code, + recordedAt, + }); + } + + private emitResolutionEvents( + eventsBySeat: Record, + session: BattleSessionRecord, + events: BattleLoggedEvent[], + nextPhase: 'awaiting_actions' | 'awaiting_replacement' | 'finished', + sentAt: string, + ) { + const resolvedTurn = nextPhase === 'awaiting_actions' && session.phase === 'awaiting_actions' + ? session.turn - 1 + : session.turn; + + for (const seat of SEATS) { + this.pushEvent(session, eventsBySeat, seat, { + type: 'battle.turn_resolved', + roomId: session.roomId, + battleId: session.battleId, + seq: this.nextSeq(session), + sentAt, + payload: { + turn: resolvedTurn, + events: projectEventsForViewer(seat, events), + postTurnVisibleState: buildRoomSnapshotPayload(session, seat, new Date(sentAt)).visibleState, + nextPhase, + }, + }); + } + } + + private emitActionRequests( + eventsBySeat: Record, + session: BattleSessionRecord, + sentAt: string, + ) { + for (const seat of SEATS) { + this.pushEvent(session, eventsBySeat, seat, { + type: 'battle.request_action', + roomId: session.roomId, + battleId: session.battleId, + seq: this.nextSeq(session), + sentAt, + payload: createActionRequestPayload(session, seat), + }); + } + } + + private emitReplacementRequests( + eventsBySeat: Record, + session: BattleSessionRecord, + sentAt: string, + ) { + for (const seat of session.pendingReplacementSeats) { + this.pushEvent(session, eventsBySeat, seat, { + type: 'battle.force_replacement', + roomId: session.roomId, + battleId: session.battleId, + seq: this.nextSeq(session), + sentAt, + payload: createReplacementRequestPayload(session, seat), + }); + } + } + + private emitBattleEnded( + eventsBySeat: Record, + session: BattleSessionRecord, + sentAt: string, + ) { + for (const seat of SEATS) { + this.pushEvent(session, eventsBySeat, seat, { + type: 'battle.ended', + roomId: session.roomId, + battleId: session.battleId, + seq: this.nextSeq(session), + sentAt, + payload: createEndedPayloadResult(session, seat), + }); + } + } + + private createEmptySeatEventMap(): Record { + return { + host: [], + guest: [], + }; + } + + private pushEvent( + session: BattleSessionRecord, + eventsBySeat: Record, + seat: RoomSeat, + event: BattleServerEventEnvelope, + ): void { + eventsBySeat[seat].push(event); + session.eventLog.push({ + seat, + type: event.type, + seq: event.seq, + sentAt: event.sentAt, + }); + } + + private nextSeq(session: BattleSessionRecord): number { + const current = session.nextSeq; + session.nextSeq += 1; + return current; + } +} diff --git a/src/server/battle/battle-turn-service.ts b/src/server/battle/battle-turn-service.ts new file mode 100644 index 00000000..2e7da935 --- /dev/null +++ b/src/server/battle/battle-turn-service.ts @@ -0,0 +1,216 @@ +import type { BattleState } from '../../core/types.js'; +import type { RoomSeat } from '../rooms/index.js'; +import { + applyReplacementBySlot, + getRemainingCount, + getSeatTeam, + hasAliveSeatPokemon, + resolveAuthoritativeTurn, + toEngineAction, +} from './battle-engine-adapter.js'; +import { + createForfeitEvent, + createLoggedEventsFromTurn, + createReplacementEvents, +} from './battle-event-log.js'; +import type { + BattleCommand, + BattleFinishReason, + BattleLoggedEvent, + BattleSessionRecord, + BattleSessionResult, +} from './battle-types.js'; + +export interface BattleTurnResolution { + nextPhase: 'awaiting_actions' | 'awaiting_replacement' | 'finished'; + events: BattleLoggedEvent[]; + result: BattleSessionResult | null; +} + +export function resolveSubmittedActions(args: { + session: BattleSessionRecord; + commands: Record; + recordedAt: string; +}): BattleTurnResolution { + const { session, commands, recordedAt } = args; + const beforeState = cloneBattleState(session.battleState); + + const hostAction = toEngineAction(session, 'host', commands.host); + const guestAction = toEngineAction(session, 'guest', commands.guest); + resolveAuthoritativeTurn(session.battleState, hostAction, guestAction); + + const events = createLoggedEventsFromTurn({ + session, + beforeState, + commands, + }); + + const result = maybeBuildResultFromBattleState(session, recordedAt); + if (result) { + session.phase = 'finished'; + session.pendingReplacementSeats = []; + session.result = result; + return { + nextPhase: 'finished', + events, + result, + }; + } + + const pendingReplacementSeats = getPendingReplacementSeats(session); + if (pendingReplacementSeats.length > 0) { + session.phase = 'awaiting_replacement'; + session.pendingReplacementSeats = pendingReplacementSeats; + return { + nextPhase: 'awaiting_replacement', + events, + result: null, + }; + } + + session.pendingReplacementSeats = []; + session.phase = 'awaiting_actions'; + session.turn += 1; + return { + nextPhase: 'awaiting_actions', + events, + result: null, + }; +} + +export function resolveSubmittedReplacements(args: { + session: BattleSessionRecord; + replacementSlots: Partial>; + recordedAt: string; +}): BattleTurnResolution { + const { session, replacementSlots, recordedAt } = args; + for (const seat of session.pendingReplacementSeats) { + const slot = replacementSlots[seat]; + if (typeof slot === 'number') { + applyReplacementBySlot(session, seat, slot); + } + } + + const events = createReplacementEvents(session, replacementSlots); + const result = maybeBuildResultFromBattleState(session, recordedAt); + if (result) { + session.phase = 'finished'; + session.pendingReplacementSeats = []; + session.result = result; + return { + nextPhase: 'finished', + events, + result, + }; + } + + session.pendingReplacementSeats = []; + session.phase = 'awaiting_actions'; + session.turn += 1; + + return { + nextPhase: 'awaiting_actions', + events, + result: null, + }; +} + +export function resolveForfeit(args: { + session: BattleSessionRecord; + forfeitingSeat: RoomSeat; + recordedAt: string; +}): BattleTurnResolution { + return resolveForfeitWithReason({ ...args, reason: 'forfeit' }); +} + +export function resolveTimeoutForfeit(args: { + session: BattleSessionRecord; + forfeitingSeat: RoomSeat; + recordedAt: string; +}): BattleTurnResolution { + return resolveForfeitWithReason({ ...args, reason: 'timeout_forfeit' }); +} + +function resolveForfeitWithReason(args: { + session: BattleSessionRecord; + forfeitingSeat: RoomSeat; + recordedAt: string; + reason: Extract; +}): BattleTurnResolution { + const { session, forfeitingSeat, recordedAt, reason } = args; + const winnerSeat: RoomSeat = forfeitingSeat === 'host' ? 'guest' : 'host'; + const result: BattleSessionResult = { + winnerSeat, + loserSeat: forfeitingSeat, + reason, + recordedAt, + }; + session.phase = 'finished'; + session.pendingReplacementSeats = []; + session.result = result; + return { + nextPhase: 'finished', + events: createForfeitEvent(forfeitingSeat), + result, + }; +} + +function getPendingReplacementSeats(session: BattleSessionRecord): RoomSeat[] { + const seats: RoomSeat[] = []; + if (needsReplacement(session, 'host')) { + seats.push('host'); + } + if (needsReplacement(session, 'guest')) { + seats.push('guest'); + } + return seats; +} + +function needsReplacement(session: BattleSessionRecord, seat: RoomSeat): boolean { + const team = getSeatTeam(session, seat); + const active = team.pokemon[team.activeIndex]; + return Boolean(active?.fainted && hasAliveSeatPokemon(session, seat)); +} + +function maybeBuildResultFromBattleState( + session: BattleSessionRecord, + recordedAt: string, +): BattleSessionResult | null { + const hostAlive = hasAliveSeatPokemon(session, 'host'); + const guestAlive = hasAliveSeatPokemon(session, 'guest'); + + if (hostAlive && guestAlive) { + return null; + } + + const winnerSeat: RoomSeat = hostAlive ? 'host' : 'guest'; + const loserSeat: RoomSeat = winnerSeat === 'host' ? 'guest' : 'host'; + const reason: BattleFinishReason = 'all_opponent_pokemon_fainted'; + + return { + winnerSeat, + loserSeat, + reason, + recordedAt, + }; +} + +function cloneBattleState(state: BattleState): BattleState { + return structuredClone(state); +} + +export function createEndedPayloadResult(session: BattleSessionRecord, viewerSeat: RoomSeat) { + const viewerWon = session.result?.winnerSeat === viewerSeat; + return { + result: viewerWon ? 'win' as const : 'loss' as const, + reason: session.result?.reason ?? 'forfeit', + finalVisibleState: { + self: { + remainingCount: getRemainingCount(session, viewerSeat), + }, + opponent: { + remainingCount: getRemainingCount(session, viewerSeat === 'host' ? 'guest' : 'host'), + }, + }, + }; +} diff --git a/src/server/battle/battle-types.ts b/src/server/battle/battle-types.ts new file mode 100644 index 00000000..f59393f0 --- /dev/null +++ b/src/server/battle/battle-types.ts @@ -0,0 +1,431 @@ +import type { BattleState, MoveData, PokemonData } from '../../core/types.js'; +import type { ActivePartySnapshot } from '../parties/index.js'; +import type { BattleRoomRecord, BattleRoomStatus, RoomSeat } from '../rooms/index.js'; +import type { PvpGeneration, RulesetKey, RulesetSummary } from '../rules/index.js'; + +export const BATTLE_COMMAND_REJECTION_CODES = { + PVP_COMMAND_PHASE_MISMATCH: 'PVP_COMMAND_PHASE_MISMATCH', + PVP_COMMAND_TURN_MISMATCH: 'PVP_COMMAND_TURN_MISMATCH', + PVP_COMMAND_DUPLICATE: 'PVP_COMMAND_DUPLICATE', + PVP_COMMAND_MOVE_INVALID: 'PVP_COMMAND_MOVE_INVALID', + PVP_COMMAND_SWITCH_INVALID: 'PVP_COMMAND_SWITCH_INVALID', + PVP_COMMAND_REPLACEMENT_INVALID: 'PVP_COMMAND_REPLACEMENT_INVALID', + PVP_COMMAND_TIMEOUT: 'PVP_COMMAND_TIMEOUT', + PVP_COMMAND_CLIENT_ID_REQUIRED: 'PVP_COMMAND_CLIENT_ID_REQUIRED', + PVP_COMMAND_BATTLE_MISMATCH: 'PVP_COMMAND_BATTLE_MISMATCH', + PVP_BATTLE_ALREADY_FINISHED: 'PVP_BATTLE_ALREADY_FINISHED', +} as const; + +export type BattleCommandRejectionCode = + (typeof BATTLE_COMMAND_REJECTION_CODES)[keyof typeof BATTLE_COMMAND_REJECTION_CODES]; + +export type BattleSessionPhase = 'awaiting_actions' | 'awaiting_replacement' | 'finished' | 'abandoned'; +export type BattleCommandPhase = Extract; +export type BattleRequestKind = 'choose_move_or_switch' | 'choose_replacement'; +export type BattleFinishReason = 'all_opponent_pokemon_fainted' | 'forfeit' | 'timeout_forfeit' | 'abandoned'; +export type BattleCommandSource = 'client' | 'timeout_auto'; + +export interface ChooseMoveCommand { + type: 'choose_move'; + moveSlot: number; +} + +export interface ChooseSwitchCommand { + type: 'choose_switch'; + targetSlot: number; +} + +export interface ChooseReplacementCommand { + type: 'choose_replacement'; + targetSlot: number; +} + +export interface ForfeitCommand { + type: 'forfeit'; +} + +export type BattleCommand = + | ChooseMoveCommand + | ChooseSwitchCommand + | ChooseReplacementCommand + | ForfeitCommand; + +export interface BattlePokemonRuntimeMetadata { + slot: number; + pokemonInstanceId: string; + speciesId: string; + nickname?: string; + levelActual: number; + levelEffective: number; + moveIds: string[]; +} + +export interface BattleSeatRuntimeState { + userId: string; + partySnapshotId: string; + partySnapshotVersion: number; + members: BattlePokemonRuntimeMetadata[]; +} + +export interface VisibleMoveOption { + slot: number; + id: string; + disabled: boolean; + currentPp?: number; +} + +export interface VisibleBenchPokemon { + slot: number; + speciesId: string; + nickname?: string; + fainted: boolean; +} + +export interface VisibleActivePokemon { + slot: number; + speciesId: string; + nickname?: string; + levelActual: number; + levelEffective: number; + hp: number; + hpMax: number; + status: string | null; + fainted: boolean; + moves?: VisibleMoveOption[]; +} + +export interface ViewerVisibleState { + self: { + active: VisibleActivePokemon; + bench: VisibleBenchPokemon[]; + }; + opponent: { + active: VisibleActivePokemon; + benchCount: number; + }; +} + +export interface BattleActionRequestPayload { + turn: number; + phase: 'awaiting_actions'; + requestId: string; + deadlineMs: number; + request: { + kind: 'choose_move_or_switch'; + activePokemon: VisibleActivePokemon; + availableMoves: VisibleMoveOption[]; + availableSwitches: VisibleBenchPokemon[]; + }; +} + +export interface BattleReplacementRequestPayload { + turn: number; + phase: 'awaiting_replacement'; + requestId: string; + deadlineMs: number; + faintedSlot: number | null; + availableReplacements: VisibleBenchPokemon[]; +} + +export interface BattleCommandAcceptedPayload { + clientCommandId: string; + turn: number; + phase: BattleCommandPhase; + lockedIn: boolean; +} + +export interface BattleCommandRejectedPayload { + clientCommandId: string; + code: BattleCommandRejectionCode; + message: string; + retryable: boolean; +} + +export interface BattleSessionResult { + winnerSeat: RoomSeat; + loserSeat: RoomSeat; + reason: BattleFinishReason; + recordedAt: string; +} + +export interface BattleMoveUsedEvent { + eventType: 'move_used'; + actorSeat: RoomSeat; + actorSlot: number; + actorSpeciesId: string; + moveSlot: number; + moveId: string; +} + +export interface BattleSwitchUsedEvent { + eventType: 'switch_used'; + actorSeat: RoomSeat; + fromSlot: number | null; + toSlot: number; + speciesId: string; +} + +export interface BattleDamageAppliedEvent { + eventType: 'damage_applied'; + targetSeat: RoomSeat; + targetSlot: number; + targetSpeciesId: string; + hp: number; + hpMax: number; + damage: number; + fainted: boolean; +} + +export interface BattleStatusAppliedEvent { + eventType: 'status_applied'; + targetSeat: RoomSeat; + targetSlot: number; + targetSpeciesId: string; + status: string; +} + +export interface BattlePokemonFaintedEvent { + eventType: 'pokemon_fainted'; + targetSeat: RoomSeat; + targetSlot: number; + targetSpeciesId: string; +} + +export interface BattleReplacementSelectedEvent { + eventType: 'replacement_selected'; + actorSeat: RoomSeat; + slot: number; + speciesId: string; +} + +export interface BattleForfeitEvent { + eventType: 'forfeit'; + actorSeat: RoomSeat; +} + +export type BattleLoggedEvent = + | BattleMoveUsedEvent + | BattleSwitchUsedEvent + | BattleDamageAppliedEvent + | BattleStatusAppliedEvent + | BattlePokemonFaintedEvent + | BattleReplacementSelectedEvent + | BattleForfeitEvent; + +export type ViewerRelativeSide = 'self' | 'opponent'; + +export type ProjectedBattleEvent = + | { + eventType: 'move_used'; + actor: ViewerRelativeSide; + actorSlot: number; + actorSpeciesId: string; + moveSlot: number; + moveId: string; + } + | { + eventType: 'switch_used'; + actor: ViewerRelativeSide; + fromSlot: number | null; + toSlot: number; + speciesId: string; + } + | { + eventType: 'damage_applied'; + target: ViewerRelativeSide; + targetSlot: number; + targetSpeciesId: string; + hp: number; + hpMax: number; + damage: number; + fainted: boolean; + } + | { + eventType: 'status_applied'; + target: ViewerRelativeSide; + targetSlot: number; + targetSpeciesId: string; + status: string; + } + | { + eventType: 'pokemon_fainted'; + target: ViewerRelativeSide; + targetSlot: number; + targetSpeciesId: string; + } + | { + eventType: 'replacement_selected'; + actor: ViewerRelativeSide; + slot: number; + speciesId: string; + } + | { + eventType: 'forfeit'; + actor: ViewerRelativeSide; + }; + +export interface BattleTurnResolvedPayload { + turn: number; + events: ProjectedBattleEvent[]; + postTurnVisibleState: ViewerVisibleState; + nextPhase: Extract; +} + +export interface BattleEndedPayload { + result: 'win' | 'loss'; + reason: BattleFinishReason; + finalVisibleState: { + self: { remainingCount: number }; + opponent: { remainingCount: number }; + }; +} + +export interface BattleCommandEnvelope { + type: 'battle.command'; + roomId: string; + battleId: string; + seq: number; + sentAt: string; + payload: { + clientCommandId: string; + turn: number; + phase: BattleCommandPhase; + command: BattleCommand; + }; +} + +interface BattleServerEventBase { + type: TType; + roomId: string; + battleId: string; + seq: number; + sentAt: string; + payload: TPayload; +} + +export type RoomSnapshotPayload = { + roomStatus: BattleRoomStatus; + battleStatus: BattleSessionPhase; + generation: PvpGeneration; + rulesetKey: RulesetKey; + yourSeat: RoomSeat; + turn: number; + visibleState: ViewerVisibleState; + pendingRequest: + | { + kind: 'choose_move_or_switch'; + deadlineMs: number; + commandSubmitted: boolean; + } + | { + kind: 'choose_replacement'; + deadlineMs: number; + commandSubmitted: boolean; + } + | null; +}; + +export type BattleServerEventEnvelope = + | BattleServerEventBase<'room.snapshot', RoomSnapshotPayload> + | BattleServerEventBase<'battle.request_action', BattleActionRequestPayload> + | BattleServerEventBase<'battle.command_accepted', BattleCommandAcceptedPayload> + | BattleServerEventBase<'battle.command_rejected', BattleCommandRejectedPayload> + | BattleServerEventBase<'battle.turn_resolved', BattleTurnResolvedPayload> + | BattleServerEventBase<'battle.force_replacement', BattleReplacementRequestPayload> + | BattleServerEventBase<'battle.ended', BattleEndedPayload>; + +export interface BattleDataResolver { + resolveSpecies(generation: PvpGeneration, speciesId: string): PokemonData | undefined; + resolveMove(generation: PvpGeneration, moveId: string): MoveData | undefined; +} + +export interface BattleSessionRecord { + roomId: string; + battleId: string; + generation: PvpGeneration; + rulesetKey: RulesetKey; + phase: BattleSessionPhase; + turn: number; + roomStatus: BattleRoomStatus; + rulesetSnapshot: RulesetSummary; + roomSnapshot: BattleRoomRecord; + battleState: BattleState; + seatState: Record; + pendingCommands: Partial>; + pendingReplacementSeats: RoomSeat[]; + pendingReplacementCommands: Partial>; + requestState: BattleRequestState | null; + timeoutState: Record; + commandLog: BattleCommandLogEntry[]; + eventLog: BattleDebugEventEntry[]; + seenClientCommandIds: string[]; + nextSeq: number; + result: BattleSessionResult | null; + createdAt: string; + updatedAt: string; +} + +export interface BattleSessionCreateInput { + room: BattleRoomRecord; + hostParty: ActivePartySnapshot; + guestParty: ActivePartySnapshot; +} + +export interface BattleSessionSubmitInput { + session: BattleSessionRecord; + seat: RoomSeat; + envelope: BattleCommandEnvelope; + source?: BattleCommandSource; +} + +export interface BattleSessionMutationResult { + session: BattleSessionRecord; + eventsBySeat: Record; +} + + +export interface BattleRequestState { + kind: BattleRequestKind; + phase: BattleCommandPhase; + turn: number; + issuedAt: string; + deadlineAt: string; + requiredSeats: RoomSeat[]; +} + +export interface BattleSeatTimeoutState { + consecutive: number; + total: number; + lastTimeoutAt: string | null; +} + +export interface BattleCommandLogEntry { + clientCommandId: string; + seat: RoomSeat; + turn: number; + phase: BattleCommandPhase; + command: BattleCommand; + source: BattleCommandSource; + accepted: boolean; + code: BattleCommandRejectionCode | null; + recordedAt: string; +} + +export interface BattleDebugEventEntry { + seat: RoomSeat; + type: BattleServerEventEnvelope['type']; + seq: number; + sentAt: string; +} + +export interface BattleDebugView { + roomId: string; + battleId: string; + phase: BattleSessionPhase; + turn: number; + requestState: BattleRequestState | null; + commands: BattleCommandLogEntry[]; + events: BattleDebugEventEntry[]; + timeouts: Record; + result: BattleSessionResult | null; +} diff --git a/src/server/battle/index.ts b/src/server/battle/index.ts new file mode 100644 index 00000000..6f20007c --- /dev/null +++ b/src/server/battle/index.ts @@ -0,0 +1,41 @@ +export { BattleSessionService, type BattleSessionServiceOptions } from './battle-session-service.js'; +export { + createTimeoutCommandEnvelope, + getTimedOutSeats, + isBattleRequestTimedOut, +} from './timeout-policy.js'; +export { + BATTLE_COMMAND_REJECTION_CODES, + type BattleActionRequestPayload, + type BattleCommand, + type BattleCommandAcceptedPayload, + type BattleCommandEnvelope, + type BattleCommandLogEntry, + type BattleCommandPhase, + type BattleCommandRejectedPayload, + type BattleCommandRejectionCode, + type BattleCommandSource, + type BattleDataResolver, + type BattleDebugEventEntry, + type BattleDebugView, + type BattleEndedPayload, + type BattleFinishReason, + type BattleLoggedEvent, + type BattleReplacementRequestPayload, + type BattleRequestKind, + type BattleRequestState, + type BattleSeatTimeoutState, + type BattleServerEventEnvelope, + type BattleSessionCreateInput, + type BattleSessionMutationResult, + type BattleSessionPhase, + type BattleSessionRecord, + type BattleSessionResult, + type BattleSessionSubmitInput, + type BattleTurnResolvedPayload, + type ProjectedBattleEvent, + type RoomSnapshotPayload, + type ViewerVisibleState, + type VisibleBenchPokemon, + type VisibleMoveOption, +} from './battle-types.js'; diff --git a/src/server/battle/timeout-policy.ts b/src/server/battle/timeout-policy.ts new file mode 100644 index 00000000..6293a505 --- /dev/null +++ b/src/server/battle/timeout-policy.ts @@ -0,0 +1,131 @@ +import type { RoomSeat } from '../rooms/index.js'; +import { getAvailableMoveSlots, getAvailableSwitchSlots } from './battle-command-service.js'; +import type { + BattleCommandEnvelope, + BattleCommandPhase, + BattleRequestState, + BattleSessionRecord, +} from './battle-types.js'; + +function createAutoCommandEnvelope(args: { + session: BattleSessionRecord; + seat: RoomSeat; + requestState: BattleRequestState; + command: BattleCommandEnvelope['payload']['command']; + now: Date; +}): BattleCommandEnvelope { + const { session, seat, requestState, command, now } = args; + return { + type: 'battle.command', + roomId: session.roomId, + battleId: session.battleId, + seq: session.nextSeq, + sentAt: now.toISOString(), + payload: { + clientCommandId: `timeout:${session.battleId}:${requestState.turn}:${requestState.phase}:${seat}:${now.getTime()}`, + turn: requestState.turn, + phase: requestState.phase, + command, + }, + }; +} + +function buildDefaultCommand(args: { + session: BattleSessionRecord; + seat: RoomSeat; + phase: BattleCommandPhase; +}) { + const { session, seat, phase } = args; + + if (phase === 'awaiting_actions') { + const moveSlots = getAvailableMoveSlots(session, seat); + if (moveSlots.length > 0) { + return { + type: 'choose_move' as const, + moveSlot: moveSlots[0], + }; + } + + const switchSlots = getAvailableSwitchSlots(session, seat); + if (switchSlots.length > 0) { + return { + type: 'choose_switch' as const, + targetSlot: switchSlots[0], + }; + } + + return { type: 'forfeit' as const }; + } + + const replacementSlots = getAvailableSwitchSlots(session, seat); + if (replacementSlots.length > 0) { + return { + type: 'choose_replacement' as const, + targetSlot: replacementSlots[0], + }; + } + + return { type: 'forfeit' as const }; +} + +export function isBattleRequestTimedOut(session: BattleSessionRecord, now: Date): boolean { + if (!session.requestState) { + return false; + } + + return now.getTime() > new Date(session.requestState.deadlineAt).getTime(); +} + +export function getTimedOutSeats(session: BattleSessionRecord, now: Date): RoomSeat[] { + if (!isBattleRequestTimedOut(session, now) || !session.requestState) { + return []; + } + + const pendingCommands = session.requestState.kind === 'choose_move_or_switch' + ? session.pendingCommands + : session.pendingReplacementCommands; + + return session.requestState.requiredSeats.filter((seat) => !pendingCommands[seat]); +} + +export function createTimeoutCommandEnvelope(args: { + session: BattleSessionRecord; + seat: RoomSeat; + now: Date; +}): BattleCommandEnvelope { + const { session, seat, now } = args; + if (!session.requestState) { + throw new Error('Cannot create a timeout command without an active request state.'); + } + + return createAutoCommandEnvelope({ + session, + seat, + requestState: session.requestState, + command: buildDefaultCommand({ + session, + seat, + phase: session.requestState.phase, + }), + now, + }); +} + +export function createTimeoutForfeitEnvelope(args: { + session: BattleSessionRecord; + seat: RoomSeat; + now: Date; +}): BattleCommandEnvelope { + const { session, seat, now } = args; + if (!session.requestState) { + throw new Error('Cannot create a timeout forfeit without an active request state.'); + } + + return createAutoCommandEnvelope({ + session, + seat, + requestState: session.requestState, + command: { type: 'forfeit' }, + now, + }); +} diff --git a/src/server/http/http-types.ts b/src/server/http/http-types.ts new file mode 100644 index 00000000..bd405990 --- /dev/null +++ b/src/server/http/http-types.ts @@ -0,0 +1,24 @@ +export interface HttpAuthContext { + playerId?: string; + operator?: boolean; +} + +export interface HttpRequest { + auth?: HttpAuthContext; + params?: Record; + body?: TBody; +} + +export interface ErrorEnvelope { + error: { + code: string; + message: string; + retryable: boolean; + details?: Record; + }; +} + +export interface HttpResponse { + status: number; + body: TBody; +} diff --git a/src/server/http/pvp-ops-routes.ts b/src/server/http/pvp-ops-routes.ts new file mode 100644 index 00000000..29b06319 --- /dev/null +++ b/src/server/http/pvp-ops-routes.ts @@ -0,0 +1,80 @@ +import type { BattleDebugView } from '../battle/battle-types.js'; +import type { ErrorEnvelope, HttpRequest, HttpResponse } from './http-types.js'; + +interface PvpOpsRoutesOptions { + getBattleDebugView(roomId: string): BattleDebugView | undefined; +} + +function errorResponse( + status: number, + code: string, + message: string, + retryable: boolean, + details?: Record, +): HttpResponse { + return { + status, + body: { + error: { + code, + message, + retryable, + details, + }, + }, + }; +} + +function requireOperator(request: HttpRequest): HttpResponse | undefined { + const playerId = request.auth?.playerId?.trim(); + if (!playerId) { + return errorResponse(401, 'PVP_UNAUTHORIZED', 'Authentication is required for PvP routes.', true); + } + + if (request.auth?.operator !== true) { + return errorResponse(403, 'PVP_OPERATOR_FORBIDDEN', 'Operator access is required for PvP ops routes.', false); + } + + return undefined; +} + +function requireRoomId(request: HttpRequest): string | HttpResponse { + const roomId = request.params?.roomId?.trim(); + if (!roomId) { + return errorResponse(400, 'PVP_INVALID_REQUEST', 'The PvP ops route requires a roomId parameter.', false); + } + + return roomId; +} + +export function createPvpOpsRoutes(options: PvpOpsRoutesOptions) { + return { + getBattleDebug(request: HttpRequest): HttpResponse { + const authError = requireOperator(request); + if (authError) { + return authError; + } + + const roomId = requireRoomId(request); + if (typeof roomId !== 'string') { + return roomId; + } + + const view = options.getBattleDebugView(roomId); + if (!view) { + return errorResponse( + 404, + 'PVP_BATTLE_DEBUG_NOT_FOUND', + 'No PvP battle debug state exists for the requested room.', + false, + { roomId }, + ); + } + + return { + status: 200, + body: view, + }; + }, + }; +} diff --git a/src/server/http/pvp-party-routes.ts b/src/server/http/pvp-party-routes.ts new file mode 100644 index 00000000..e46d622e --- /dev/null +++ b/src/server/http/pvp-party-routes.ts @@ -0,0 +1,214 @@ +import { + PartyRegistrationService, + PartyRegistrationServiceError, + type ActivePartySnapshot, + type GrowthProofInput, + type OnlinePartyMemberInput, + type RegisterActivePartyResult, +} from '../parties/index.js'; +import type { ErrorEnvelope, HttpRequest, HttpResponse } from './http-types.js'; + +interface PutActivePartyBody { + sourceStateHash: string; + sourceConfigHash: string; + clientBuild?: string; + members: OnlinePartyMemberInput[]; + growthProof: GrowthProofInput; +} + +interface PvpPartyRoutesOptions { + service?: PartyRegistrationService; +} + +interface ActivePartyResponseBody { + generation: RegisterActivePartyResult['generation']; + rulesetKey: RegisterActivePartyResult['rulesetKey']; + party: ReturnType; +} + +interface PutActivePartyResponseBody extends ActivePartyResponseBody { + changed: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isGrowthProofInput(value: unknown): value is GrowthProofInput { + if (!isRecord(value)) { + return false; + } + + return typeof value.proofVersion === 'string' + && typeof value.capturedAt === 'string' + && typeof value.sourceSaveId === 'string' + && typeof value.sourceSaveRevision === 'number' + && isRecord(value.cheatFlags) + && typeof value.cheatFlags.hasCheatHistory === 'boolean' + && Array.isArray(value.cheatFlags.flags) + && Array.isArray(value.memberProofs); +} + +function isOnlinePartyMemberInput(value: unknown): value is OnlinePartyMemberInput { + if (!isRecord(value)) { + return false; + } + + return typeof value.slot === 'number' + && typeof value.pokemonInstanceId === 'string' + && typeof value.speciesId === 'string' + && typeof value.levelActual === 'number' + && Array.isArray(value.moves) + && (value.nickname === undefined || typeof value.nickname === 'string'); +} + +function isPutActivePartyBody(value: unknown): value is PutActivePartyBody { + if (!isRecord(value)) { + return false; + } + + return typeof value.sourceStateHash === 'string' + && typeof value.sourceConfigHash === 'string' + && (value.clientBuild === undefined || typeof value.clientBuild === 'string') + && Array.isArray(value.members) + && value.members.every(isOnlinePartyMemberInput) + && isGrowthProofInput(value.growthProof); +} + +function toErrorResponse(error: PartyRegistrationServiceError): HttpResponse { + return { + status: error.status, + body: { + error: { + code: error.code, + message: error.message, + retryable: error.retryable, + details: error.details, + }, + }, + }; +} + +function requirePlayerId(request: HttpRequest): string | HttpResponse { + const playerId = request.auth?.playerId?.trim(); + if (!playerId) { + return { + status: 401, + body: { + error: { + code: 'PVP_UNAUTHORIZED', + message: 'Authentication is required for PvP routes.', + retryable: true, + }, + }, + }; + } + + return playerId; +} + +function invalidRequest(message: string, details?: Record): HttpResponse { + return { + status: 400, + body: { + error: { + code: 'PVP_INVALID_REQUEST', + message, + retryable: false, + details, + }, + }, + }; +} + +function serializeParty(snapshot: ActivePartySnapshot) { + return { + snapshotId: snapshot.snapshotId, + snapshotVersion: snapshot.snapshotVersion, + status: snapshot.status, + registeredAt: snapshot.registeredAt, + sourceStateHash: snapshot.sourceStateHash, + sourceConfigHash: snapshot.sourceConfigHash, + validationStatus: snapshot.validationStatus, + partySummary: structuredClone(snapshot.partySummary), + members: structuredClone(snapshot.members), + }; +} + +export function createPvpPartyRoutes(options: PvpPartyRoutesOptions = {}) { + const service = options.service ?? new PartyRegistrationService(); + + return { + getActiveParty( + request: HttpRequest, + ): HttpResponse { + const playerId = requirePlayerId(request); + if (typeof playerId !== 'string') { + return playerId; + } + + try { + const activeParty = service.getActiveParty({ + playerId, + generation: request.params?.generation ?? '', + }); + + return { + status: 200, + body: { + generation: activeParty.generation, + rulesetKey: activeParty.rulesetKey, + party: serializeParty(activeParty), + }, + }; + } catch (error) { + if (error instanceof PartyRegistrationServiceError) { + return toErrorResponse(error); + } + + throw error; + } + }, + + putActiveParty( + request: HttpRequest, + ): HttpResponse { + const playerId = requirePlayerId(request); + if (typeof playerId !== 'string') { + return playerId; + } + + if (!isPutActivePartyBody(request.body)) { + return invalidRequest('The PvP party registration payload is malformed.'); + } + + try { + const result = service.registerActiveParty({ + playerId, + generation: request.params?.generation ?? '', + sourceStateHash: request.body.sourceStateHash, + sourceConfigHash: request.body.sourceConfigHash, + clientBuild: request.body.clientBuild, + members: structuredClone(request.body.members), + growthProof: structuredClone(request.body.growthProof), + }); + + return { + status: 200, + body: { + generation: result.generation, + rulesetKey: result.rulesetKey, + changed: result.changed, + party: serializeParty(result.party), + }, + }; + } catch (error) { + if (error instanceof PartyRegistrationServiceError) { + return toErrorResponse(error); + } + + throw error; + } + }, + }; +} diff --git a/src/server/http/pvp-room-routes.ts b/src/server/http/pvp-room-routes.ts new file mode 100644 index 00000000..4f537981 --- /dev/null +++ b/src/server/http/pvp-room-routes.ts @@ -0,0 +1,194 @@ +import { projectRoomView, RoomProjectionError, type RoomView } from '../projection/index.js'; +import { RoomService, RoomServiceError } from '../rooms/index.js'; +import type { ErrorEnvelope, HttpRequest, HttpResponse } from './http-types.js'; + +interface CreateRoomBody { + generation: string; + visibility: string; + rulesetKey?: string; +} + +interface JoinRoomBody { + roomCode: string; + generation: string; +} + +interface PvpRoomRoutesOptions { + service?: RoomService; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isCreateRoomBody(value: unknown): value is CreateRoomBody { + if (!isRecord(value)) { + return false; + } + + return typeof value.generation === 'string' + && typeof value.visibility === 'string' + && (value.rulesetKey === undefined || typeof value.rulesetKey === 'string'); +} + +function isJoinRoomBody(value: unknown): value is JoinRoomBody { + if (!isRecord(value)) { + return false; + } + + return typeof value.roomCode === 'string' && typeof value.generation === 'string'; +} + +function requirePlayerId(request: HttpRequest): string | HttpResponse { + const playerId = request.auth?.playerId?.trim(); + if (!playerId) { + return { + status: 401, + body: { + error: { + code: 'PVP_UNAUTHORIZED', + message: 'Authentication is required for PvP routes.', + retryable: true, + }, + }, + }; + } + + return playerId; +} + +function invalidRequest(message: string, details?: Record): HttpResponse { + return { + status: 400, + body: { + error: { + code: 'PVP_INVALID_REQUEST', + message, + retryable: false, + details, + }, + }, + }; +} + +function toErrorResponse(error: RoomServiceError | RoomProjectionError): HttpResponse { + return { + status: error.status, + body: { + error: { + code: error.code, + message: error.message, + retryable: error.retryable, + details: error.details, + }, + }, + }; +} + +function requireRoomId(request: HttpRequest): string | HttpResponse { + const roomId = request.params?.roomId?.trim(); + if (!roomId) { + return invalidRequest('The PvP room route requires a roomId parameter.'); + } + + return roomId; +} + +export function createPvpRoomRoutes(options: PvpRoomRoutesOptions = {}) { + const service = options.service ?? new RoomService(); + + return { + createRoom(request: HttpRequest): HttpResponse { + const playerId = requirePlayerId(request); + if (typeof playerId !== 'string') { + return playerId; + } + + if (!isCreateRoomBody(request.body)) { + return invalidRequest('The PvP room create payload is malformed.'); + } + + try { + const room = service.createRoom({ + playerId, + generation: request.body.generation, + visibility: request.body.visibility, + rulesetKey: request.body.rulesetKey, + }); + + return { + status: 200, + body: projectRoomView(room, playerId), + }; + } catch (error) { + if (error instanceof RoomServiceError || error instanceof RoomProjectionError) { + return toErrorResponse(error); + } + + throw error; + } + }, + + joinRoom(request: HttpRequest): HttpResponse { + const playerId = requirePlayerId(request); + if (typeof playerId !== 'string') { + return playerId; + } + + const roomId = requireRoomId(request); + if (typeof roomId !== 'string') { + return roomId; + } + + if (!isJoinRoomBody(request.body)) { + return invalidRequest('The PvP room join payload is malformed.'); + } + + try { + const room = service.joinRoom({ + playerId, + roomId, + roomCode: request.body.roomCode, + generation: request.body.generation, + }); + + return { + status: 200, + body: projectRoomView(room, playerId), + }; + } catch (error) { + if (error instanceof RoomServiceError || error instanceof RoomProjectionError) { + return toErrorResponse(error); + } + + throw error; + } + }, + + getRoom(request: HttpRequest): HttpResponse { + const playerId = requirePlayerId(request); + if (typeof playerId !== 'string') { + return playerId; + } + + const roomId = requireRoomId(request); + if (typeof roomId !== 'string') { + return roomId; + } + + try { + const room = service.getRoom(roomId); + return { + status: 200, + body: projectRoomView(room, playerId), + }; + } catch (error) { + if (error instanceof RoomServiceError || error instanceof RoomProjectionError) { + return toErrorResponse(error); + } + + throw error; + } + }, + }; +} diff --git a/src/server/http/pvp-rules-routes.ts b/src/server/http/pvp-rules-routes.ts new file mode 100644 index 00000000..d188cf72 --- /dev/null +++ b/src/server/http/pvp-rules-routes.ts @@ -0,0 +1,70 @@ +import type { RulesetSummary } from '../rules/index.js'; +import { PartyRegistrationService, PartyRegistrationServiceError } from '../parties/index.js'; +import type { ErrorEnvelope, HttpRequest, HttpResponse } from './http-types.js'; + +interface PvpRulesRoutesOptions { + service?: PartyRegistrationService; +} + +function toErrorResponse(error: PartyRegistrationServiceError): HttpResponse { + return { + status: error.status, + body: { + error: { + code: error.code, + message: error.message, + retryable: error.retryable, + details: error.details, + }, + }, + }; +} + +function requirePlayerId(request: HttpRequest): string | HttpResponse { + const playerId = request.auth?.playerId?.trim(); + if (!playerId) { + return { + status: 401, + body: { + error: { + code: 'PVP_UNAUTHORIZED', + message: 'Authentication is required for PvP routes.', + retryable: true, + }, + }, + }; + } + + return playerId; +} + +function serializeRuleset(ruleset: RulesetSummary): RulesetSummary { + return structuredClone(ruleset); +} + +export function createPvpRulesRoutes(options: PvpRulesRoutesOptions = {}) { + const service = options.service ?? new PartyRegistrationService(); + + return { + getRuleset(request: HttpRequest): HttpResponse { + const playerId = requirePlayerId(request); + if (typeof playerId !== 'string') { + return playerId; + } + + try { + const ruleset = service.getRuleset(request.params?.generation ?? ''); + return { + status: 200, + body: serializeRuleset(ruleset), + }; + } catch (error) { + if (error instanceof PartyRegistrationServiceError) { + return toErrorResponse(error); + } + + throw error; + } + }, + }; +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 00000000..4283f467 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,11 @@ +export { createPvpPartyRoutes } from './http/pvp-party-routes.js'; +export { createPvpOpsRoutes } from './http/pvp-ops-routes.js'; +export { createPvpRulesRoutes } from './http/pvp-rules-routes.js'; +export { createPvpRoomRoutes } from './http/pvp-room-routes.js'; +export type { ErrorEnvelope, HttpRequest, HttpResponse } from './http/http-types.js'; +export * from './battle/index.js'; +export * from './parties/index.js'; +export * from './rules/index.js'; +export * from './rooms/index.js'; +export * from './projection/index.js'; +export * from './ws/index.js'; diff --git a/src/server/parties/growth-proof.ts b/src/server/parties/growth-proof.ts new file mode 100644 index 00000000..76d3fe27 --- /dev/null +++ b/src/server/parties/growth-proof.ts @@ -0,0 +1,95 @@ +import type { + GrowthProofInput, + GrowthProofMemberInput, + OnlinePartyMemberInput, + PartyValidationIssue, +} from './party-types.js'; +import { PARTY_VALIDATION_ERROR_CODES } from './party-types.js'; + +const SUPPORTED_GROWTH_PROOF_VERSION = 'v1'; + +export interface NormalizedGrowthProof { + proofVersion: string; + capturedAt: string; + sourceSaveId: string; + sourceSaveRevision: number; + memberProofs: GrowthProofMemberInput[]; +} + +export function validateGrowthProof( + growthProof: GrowthProofInput, + members: OnlinePartyMemberInput[], +): { issues: PartyValidationIssue[]; normalizedProof?: NormalizedGrowthProof } { + const issues: PartyValidationIssue[] = []; + + if (growthProof.proofVersion !== SUPPORTED_GROWTH_PROOF_VERSION) { + issues.push({ + code: PARTY_VALIDATION_ERROR_CODES.PVP_GROWTH_PROOF_VERSION_UNSUPPORTED, + field: 'growthProof.proofVersion', + meta: { proofVersion: growthProof.proofVersion }, + }); + } + + if (growthProof.cheatFlags?.hasCheatHistory === true) { + issues.push({ + code: PARTY_VALIDATION_ERROR_CODES.PVP_CHEAT_SAVE_DISALLOWED, + field: 'growthProof.cheatFlags.hasCheatHistory', + }); + } + + if (growthProof.memberProofs.length !== members.length) { + issues.push({ + code: PARTY_VALIDATION_ERROR_CODES.PVP_GROWTH_PROOF_MEMBER_COUNT_MISMATCH, + field: 'growthProof.memberProofs', + meta: { + expectedCount: members.length, + actualCount: growthProof.memberProofs.length, + }, + }); + } + + const proofBySlot = new Map(); + for (const proof of growthProof.memberProofs) { + proofBySlot.set(proof.slot, proof); + } + + for (const member of members) { + const proof = proofBySlot.get(member.slot); + if (!proof) { + issues.push({ + code: PARTY_VALIDATION_ERROR_CODES.PVP_GROWTH_PROOF_MEMBER_MISMATCH, + field: `growthProof.memberProofs[slot=${member.slot}]`, + meta: { reason: 'missing-proof', slot: member.slot }, + }); + continue; + } + + if ( + proof.slot !== member.slot + || proof.pokemonInstanceId !== member.pokemonInstanceId + || proof.speciesId !== member.speciesId + || proof.levelActual !== member.levelActual + ) { + issues.push({ + code: PARTY_VALIDATION_ERROR_CODES.PVP_GROWTH_PROOF_MEMBER_MISMATCH, + field: `growthProof.memberProofs[slot=${member.slot}]`, + meta: { reason: 'member-mismatch', slot: member.slot }, + }); + } + } + + if (issues.length > 0) { + return { issues }; + } + + return { + issues, + normalizedProof: { + proofVersion: growthProof.proofVersion, + capturedAt: growthProof.capturedAt, + sourceSaveId: growthProof.sourceSaveId, + sourceSaveRevision: growthProof.sourceSaveRevision, + memberProofs: [...growthProof.memberProofs].sort((left, right) => left.slot - right.slot), + }, + }; +} diff --git a/src/server/parties/index.ts b/src/server/parties/index.ts new file mode 100644 index 00000000..ead025fa --- /dev/null +++ b/src/server/parties/index.ts @@ -0,0 +1,27 @@ +export { validateGrowthProof } from './growth-proof.js'; +export { validateOnlineParty } from './party-validator.js'; +export { + InMemoryPartySnapshotRepository, + type PartySnapshotRepository, +} from './party-snapshot-repository.js'; +export { + PartyRegistrationService, + PartyRegistrationServiceError, + type PartyRegistrationServiceOptions, +} from './party-registration-service.js'; +export { PARTY_VALIDATION_ERROR_CODES } from './party-types.js'; +export type { + ActivePartySnapshot, + GrowthProofInput, + GrowthProofMemberInput, + OnlinePartyMemberInput, + OnlinePartyMemberSnapshotDraft, + OnlinePartySnapshotDraft, + PartyValidationErrorCode, + PartyValidationIssue, + PartyValidationResult, + RegisterActivePartyInput, + RegisterActivePartyResult, + ValidateOnlinePartyInput, +} from './party-types.js'; +export type { PvpGeneration } from '../rules/index.js'; diff --git a/src/server/parties/party-registration-service.ts b/src/server/parties/party-registration-service.ts new file mode 100644 index 00000000..63d97085 --- /dev/null +++ b/src/server/parties/party-registration-service.ts @@ -0,0 +1,262 @@ +import { getRulesetByGeneration, type PvpGeneration, type RulesetSummary } from '../rules/index.js'; +import { + InMemoryPartySnapshotRepository, + type PartySnapshotRepository, +} from './party-snapshot-repository.js'; +import { validateOnlineParty } from './party-validator.js'; +import { + PARTY_VALIDATION_ERROR_CODES, + type ActivePartySnapshot, + type PartyValidationIssue, + type RegisterActivePartyInput, + type RegisterActivePartyResult, +} from './party-types.js'; + +export interface PartyRegistrationServiceOptions { + repository?: PartySnapshotRepository; + now?: () => string; +} + +interface ServiceErrorOptions { + status: number; + code: string; + message: string; + retryable: boolean; + details?: Record; +} + +function stableStringify(value: unknown): string { + return JSON.stringify(value); +} + +function cloneSnapshot(snapshot: ActivePartySnapshot): ActivePartySnapshot { + return structuredClone(snapshot); +} + +function buildValidationDetails( + generation: PvpGeneration, + issues: PartyValidationIssue[], +): Record { + const [firstIssue] = issues; + + return { + generation, + issueCount: issues.length, + field: firstIssue?.field, + issues: issues.map((issue) => ({ + code: issue.code, + field: issue.field, + meta: issue.meta, + })), + ...firstIssue?.meta, + }; +} + +function mapValidationStatus(issueCode: PartyValidationIssue['code']): number { + switch (issueCode) { + case PARTY_VALIDATION_ERROR_CODES.PVP_CHEAT_SAVE_DISALLOWED: + return 403; + default: + return 422; + } +} + +function mapValidationMessage(issueCode: PartyValidationIssue['code']): string { + switch (issueCode) { + case PARTY_VALIDATION_ERROR_CODES.PVP_CHEAT_SAVE_DISALLOWED: + return 'Cheat-contaminated saves cannot be registered for online PvP.'; + case PARTY_VALIDATION_ERROR_CODES.PVP_PARTY_SIZE_INVALID: + return 'Online PvP parties must contain exactly 6 members.'; + case PARTY_VALIDATION_ERROR_CODES.PVP_PARTY_SLOT_DUPLICATE: + return 'Duplicate party slots are not allowed.'; + case PARTY_VALIDATION_ERROR_CODES.PVP_SPECIES_DUPLICATE: + return 'Duplicate species are not allowed in an online party.'; + case PARTY_VALIDATION_ERROR_CODES.PVP_SPECIAL_LIMIT_LEGENDARY_MYTHICAL_EXCEEDED: + return 'Legendary and mythical Pokémon exceed the allowed total.'; + case PARTY_VALIDATION_ERROR_CODES.PVP_SPECIAL_LIMIT_RESTRICTED_EXCEEDED: + return 'Restricted Pokémon exceed the allowed total.'; + case PARTY_VALIDATION_ERROR_CODES.PVP_GROWTH_PROOF_VERSION_UNSUPPORTED: + return 'The submitted growth proof version is not supported.'; + case PARTY_VALIDATION_ERROR_CODES.PVP_GROWTH_PROOF_MEMBER_COUNT_MISMATCH: + case PARTY_VALIDATION_ERROR_CODES.PVP_GROWTH_PROOF_MEMBER_MISMATCH: + return 'The submitted growth proof does not match the registered party.'; + case PARTY_VALIDATION_ERROR_CODES.PVP_MOVES_INVALID: + return 'The submitted moveset is invalid for online PvP registration.'; + default: + return 'The submitted party failed online PvP validation.'; + } +} + +function buildRegistrationFingerprint(snapshot: ActivePartySnapshot): string { + return stableStringify({ + rulesetKey: snapshot.rulesetKey, + sourceStateHash: snapshot.sourceStateHash, + sourceConfigHash: snapshot.sourceConfigHash, + proofVersion: snapshot.proofVersion, + sourceSaveId: snapshot.sourceSaveId, + sourceSaveRevision: snapshot.sourceSaveRevision, + partySummary: snapshot.partySummary, + members: snapshot.members, + }); +} + +function buildCandidateFingerprint( + input: RegisterActivePartyInput, + ruleset: RulesetSummary, + snapshot: RegisterActivePartyResult['party'], +): string { + return stableStringify({ + rulesetKey: ruleset.rulesetKey, + sourceStateHash: input.sourceStateHash, + sourceConfigHash: input.sourceConfigHash, + proofVersion: input.growthProof.proofVersion, + sourceSaveId: input.growthProof.sourceSaveId, + sourceSaveRevision: input.growthProof.sourceSaveRevision, + partySummary: snapshot.partySummary, + members: snapshot.members, + }); +} + +export class PartyRegistrationServiceError extends Error { + readonly status: number; + + readonly code: string; + + readonly retryable: boolean; + + readonly details?: Record; + + constructor(options: ServiceErrorOptions) { + super(options.message); + this.name = 'PartyRegistrationServiceError'; + this.status = options.status; + this.code = options.code; + this.retryable = options.retryable; + this.details = options.details; + } +} + +export class PartyRegistrationService { + private readonly repository: PartySnapshotRepository; + + private readonly now: () => string; + + constructor(options: PartyRegistrationServiceOptions = {}) { + this.repository = options.repository ?? new InMemoryPartySnapshotRepository(); + this.now = options.now ?? (() => new Date().toISOString()); + } + + getRuleset(generation: string): RulesetSummary { + return structuredClone(this.resolveRuleset(generation)); + } + + getActiveParty(input: { playerId: string; generation: string }): ActivePartySnapshot { + const ruleset = this.resolveRuleset(input.generation); + const activeSnapshot = this.repository.getActiveSnapshot(input.playerId, ruleset.generation); + + if (!activeSnapshot) { + throw new PartyRegistrationServiceError({ + status: 404, + code: 'PVP_ACTIVE_PARTY_NOT_FOUND', + message: 'No active online party is registered for this generation.', + retryable: false, + details: { generation: ruleset.generation }, + }); + } + + if (activeSnapshot.rulesetKey !== ruleset.rulesetKey) { + throw new PartyRegistrationServiceError({ + status: 409, + code: 'PVP_RULESET_MISMATCH', + message: 'The active party is pinned to an outdated PvP ruleset.', + retryable: true, + details: { + generation: ruleset.generation, + activeRulesetKey: ruleset.rulesetKey, + snapshotRulesetKey: activeSnapshot.rulesetKey, + snapshotId: activeSnapshot.snapshotId, + }, + }); + } + + return cloneSnapshot(activeSnapshot); + } + + registerActiveParty(input: RegisterActivePartyInput): RegisterActivePartyResult { + const ruleset = this.resolveRuleset(input.generation); + const validationResult = validateOnlineParty({ + generation: ruleset.generation, + members: structuredClone(input.members), + growthProof: structuredClone(input.growthProof), + }); + + if (!validationResult.ok) { + const [primaryIssue] = validationResult.issues; + const primaryCode = primaryIssue?.code ?? 'PVP_INVALID_REQUEST'; + throw new PartyRegistrationServiceError({ + status: mapValidationStatus(primaryCode), + code: primaryCode, + message: mapValidationMessage(primaryCode), + retryable: false, + details: buildValidationDetails(ruleset.generation, validationResult.issues), + }); + } + + const activeSnapshot = this.repository.getActiveSnapshot(input.playerId, ruleset.generation); + const candidateSnapshot: ActivePartySnapshot = { + ...validationResult.snapshot, + snapshotId: '__candidate__', + snapshotVersion: activeSnapshot?.snapshotVersion ?? 1, + playerId: input.playerId, + generation: ruleset.generation, + rulesetKey: ruleset.rulesetKey, + status: 'active', + isActive: true, + registeredAt: activeSnapshot?.registeredAt ?? this.now(), + sourceStateHash: input.sourceStateHash, + sourceConfigHash: input.sourceConfigHash, + clientBuild: input.clientBuild, + growthProof: structuredClone(input.growthProof), + }; + const candidateFingerprint = buildCandidateFingerprint(input, ruleset, candidateSnapshot); + + if (activeSnapshot && buildRegistrationFingerprint(activeSnapshot) === candidateFingerprint) { + return { + generation: ruleset.generation, + rulesetKey: ruleset.rulesetKey, + changed: false, + party: cloneSnapshot(activeSnapshot), + }; + } + + const nextSnapshot: ActivePartySnapshot = { + ...candidateSnapshot, + snapshotId: this.repository.createSnapshotId(ruleset.generation), + snapshotVersion: this.repository.getNextSnapshotVersion(input.playerId, ruleset.generation), + registeredAt: this.now(), + }; + + this.repository.replaceActiveSnapshot(nextSnapshot); + + return { + generation: ruleset.generation, + rulesetKey: ruleset.rulesetKey, + changed: true, + party: cloneSnapshot(nextSnapshot), + }; + } + + private resolveRuleset(generation: string): RulesetSummary { + try { + return getRulesetByGeneration(generation); + } catch { + throw new PartyRegistrationServiceError({ + status: 404, + code: 'PVP_RULESET_NOT_FOUND', + message: 'No active PvP ruleset is configured for this generation.', + retryable: false, + details: { generation }, + }); + } + } +} diff --git a/src/server/parties/party-snapshot-repository.ts b/src/server/parties/party-snapshot-repository.ts new file mode 100644 index 00000000..66629473 --- /dev/null +++ b/src/server/parties/party-snapshot-repository.ts @@ -0,0 +1,106 @@ +import type { PvpGeneration } from '../rules/index.js'; +import type { ActivePartySnapshot } from './party-types.js'; + +export interface PartySnapshotRepository { + getActiveSnapshot(playerId: string, generation: PvpGeneration): ActivePartySnapshot | undefined; + listSnapshots(playerId: string, generation: PvpGeneration): readonly ActivePartySnapshot[]; + replaceActiveSnapshot(snapshot: ActivePartySnapshot): void; + createSnapshotId(generation: PvpGeneration): string; + getNextSnapshotVersion(playerId: string, generation: PvpGeneration): number; + seedSnapshots(snapshots: readonly ActivePartySnapshot[]): void; +} + +function createRepositoryKey(playerId: string, generation: PvpGeneration): string { + return `${playerId}:${generation}`; +} + +function extractSnapshotSequence(snapshotId: string): number | undefined { + const matched = /^ops_gen\d+_(\d{6})$/.exec(snapshotId); + if (!matched) { + return undefined; + } + + return Number(matched[1]); +} + +export class InMemoryPartySnapshotRepository implements PartySnapshotRepository { + private readonly snapshotsByKey = new Map(); + + private readonly sequenceByGeneration = new Map(); + + getActiveSnapshot(playerId: string, generation: PvpGeneration): ActivePartySnapshot | undefined { + return this.snapshotsByKey + .get(createRepositoryKey(playerId, generation)) + ?.find((snapshot) => snapshot.isActive); + } + + listSnapshots(playerId: string, generation: PvpGeneration): readonly ActivePartySnapshot[] { + return this.snapshotsByKey.get(createRepositoryKey(playerId, generation)) ?? []; + } + + replaceActiveSnapshot(snapshot: ActivePartySnapshot): void { + const key = createRepositoryKey(snapshot.playerId, snapshot.generation); + const existingSnapshots = this.snapshotsByKey.get(key) ?? []; + const deactivatedSnapshots = existingSnapshots.map((existingSnapshot) => ({ + ...existingSnapshot, + isActive: false, + })); + + this.snapshotsByKey.set(key, [...deactivatedSnapshots, { ...snapshot, isActive: true }]); + this.syncSequence(snapshot); + } + + createSnapshotId(generation: PvpGeneration): string { + const nextSequence = (this.sequenceByGeneration.get(generation) ?? 0) + 1; + this.sequenceByGeneration.set(generation, nextSequence); + + return `ops_${generation}_${String(nextSequence).padStart(6, '0')}`; + } + + getNextSnapshotVersion(playerId: string, generation: PvpGeneration): number { + const snapshots = this.snapshotsByKey.get(createRepositoryKey(playerId, generation)) ?? []; + const highestVersion = snapshots.reduce( + (currentHighest, snapshot) => Math.max(currentHighest, snapshot.snapshotVersion), + 0, + ); + + return highestVersion + 1; + } + + seedSnapshots(snapshots: readonly ActivePartySnapshot[]): void { + const nextSnapshotsByKey = new Map(this.snapshotsByKey); + + for (const snapshot of snapshots) { + const key = createRepositoryKey(snapshot.playerId, snapshot.generation); + const seededSnapshots = nextSnapshotsByKey.get(key) ?? []; + + if (snapshot.isActive) { + nextSnapshotsByKey.set( + key, + [...seededSnapshots.map((existingSnapshot) => ({ ...existingSnapshot, isActive: false })), snapshot], + ); + } else { + nextSnapshotsByKey.set(key, [...seededSnapshots, snapshot]); + } + + this.syncSequence(snapshot); + } + + this.snapshotsByKey.clear(); + for (const [key, seededSnapshots] of nextSnapshotsByKey.entries()) { + this.snapshotsByKey.set(key, seededSnapshots.map((snapshot) => ({ ...snapshot }))); + } + } + + private syncSequence(snapshot: ActivePartySnapshot): void { + const sequence = extractSnapshotSequence(snapshot.snapshotId); + if (!sequence) { + return; + } + + const currentSequence = this.sequenceByGeneration.get(snapshot.generation) ?? 0; + if (sequence > currentSequence) { + this.sequenceByGeneration.set(snapshot.generation, sequence); + } + } +} diff --git a/src/server/parties/party-types.ts b/src/server/parties/party-types.ts new file mode 100644 index 00000000..71e6e3ae --- /dev/null +++ b/src/server/parties/party-types.ts @@ -0,0 +1,139 @@ +import type { PvpGeneration, RulesetKey } from '../rules/index.js'; + +export const PARTY_VALIDATION_ERROR_CODES = { + PVP_PARTY_SIZE_INVALID: 'PVP_PARTY_SIZE_INVALID', + PVP_PARTY_SLOT_INVALID: 'PVP_PARTY_SLOT_INVALID', + PVP_PARTY_SLOT_DUPLICATE: 'PVP_PARTY_SLOT_DUPLICATE', + PVP_PARTY_INSTANCE_ID_INVALID: 'PVP_PARTY_INSTANCE_ID_INVALID', + PVP_SPECIES_INVALID: 'PVP_SPECIES_INVALID', + PVP_SPECIES_UNKNOWN: 'PVP_SPECIES_UNKNOWN', + PVP_LEVEL_INVALID: 'PVP_LEVEL_INVALID', + PVP_MOVES_INVALID: 'PVP_MOVES_INVALID', + PVP_SPECIES_DUPLICATE: 'PVP_SPECIES_DUPLICATE', + PVP_SPECIAL_LIMIT_LEGENDARY_MYTHICAL_EXCEEDED: 'PVP_SPECIAL_LIMIT_LEGENDARY_MYTHICAL_EXCEEDED', + PVP_SPECIAL_LIMIT_RESTRICTED_EXCEEDED: 'PVP_SPECIAL_LIMIT_RESTRICTED_EXCEEDED', + PVP_CHEAT_SAVE_DISALLOWED: 'PVP_CHEAT_SAVE_DISALLOWED', + PVP_GROWTH_PROOF_VERSION_UNSUPPORTED: 'PVP_GROWTH_PROOF_VERSION_UNSUPPORTED', + PVP_GROWTH_PROOF_MEMBER_COUNT_MISMATCH: 'PVP_GROWTH_PROOF_MEMBER_COUNT_MISMATCH', + PVP_GROWTH_PROOF_MEMBER_MISMATCH: 'PVP_GROWTH_PROOF_MEMBER_MISMATCH', +} as const; + +export type PartyValidationErrorCode = + (typeof PARTY_VALIDATION_ERROR_CODES)[keyof typeof PARTY_VALIDATION_ERROR_CODES]; + +export interface OnlinePartyMemberInput { + slot: number; + pokemonInstanceId: string; + speciesId: string; + nickname?: string; + levelActual: number; + moves: string[]; +} + +export interface GrowthProofMemberInput { + slot: number; + pokemonInstanceId: string; + speciesId: string; + levelActual: number; + movesHash: string; + stateHash: string; +} + +export interface GrowthProofInput { + proofVersion: string; + capturedAt: string; + sourceSaveId: string; + sourceSaveRevision: number; + cheatFlags: { + hasCheatHistory: boolean; + flags: string[]; + }; + memberProofs: GrowthProofMemberInput[]; +} + +export interface PartyValidationIssue { + code: PartyValidationErrorCode; + field?: string; + meta?: Record; +} + +export interface OnlinePartyMemberSnapshotDraft { + slot: number; + pokemonInstanceId: string; + speciesId: string; + nickname?: string; + levelActual: number; + levelEffective: number; + specialClass: { + legendary: boolean; + mythical: boolean; + restricted: boolean; + }; + moves: string[]; +} + +export interface OnlinePartySnapshotDraft { + generation: PvpGeneration; + rulesetKey: RulesetKey; + validationStatus: 'accepted'; + proofVersion: string; + capturedAt: string; + sourceSaveId: string; + sourceSaveRevision: number; + partySummary: { + memberCount: number; + legendaryMythicalCount: number; + restrictedCount: number; + speciesDupClause: boolean; + }; + members: OnlinePartyMemberSnapshotDraft[]; +} + +export interface ValidateOnlinePartyInput { + generation: PvpGeneration; + members: OnlinePartyMemberInput[]; + growthProof: GrowthProofInput; +} + +export type PartyValidationResult = + | { + ok: true; + snapshot: OnlinePartySnapshotDraft; + } + | { + ok: false; + errorCodes: PartyValidationErrorCode[]; + issues: PartyValidationIssue[]; + }; + +export interface ActivePartySnapshot extends OnlinePartySnapshotDraft { + snapshotId: string; + snapshotVersion: number; + playerId: string; + generation: PvpGeneration; + rulesetKey: RulesetKey; + status: 'active'; + isActive: boolean; + registeredAt: string; + sourceStateHash: string; + sourceConfigHash: string; + clientBuild?: string; + growthProof: GrowthProofInput; +} + +export interface RegisterActivePartyInput { + playerId: string; + generation: string; + sourceStateHash: string; + sourceConfigHash: string; + clientBuild?: string; + members: OnlinePartyMemberInput[]; + growthProof: GrowthProofInput; +} + +export interface RegisterActivePartyResult { + generation: PvpGeneration; + rulesetKey: RulesetKey; + changed: boolean; + party: ActivePartySnapshot; +} diff --git a/src/server/parties/party-validator.ts b/src/server/parties/party-validator.ts new file mode 100644 index 00000000..dab95a5a --- /dev/null +++ b/src/server/parties/party-validator.ts @@ -0,0 +1,282 @@ +import { getPokemonDB, speciesIdToGeneration } from '../../core/pokemon-data.js'; +import type { Rarity } from '../../core/types.js'; +import { + computeGenerationEffectiveLevel, + getRulesetByGeneration, + isRestrictedSpecies, +} from '../rules/index.js'; +import { validateGrowthProof } from './growth-proof.js'; +import { + PARTY_VALIDATION_ERROR_CODES, + type OnlinePartyMemberSnapshotDraft, + type OnlinePartySnapshotDraft, + type PartyValidationIssue, + type PartyValidationResult, + type ValidateOnlinePartyInput, +} from './party-types.js'; + +interface NormalizedCandidateMember { + slot: number; + pokemonInstanceId: string; + speciesId: string; + nickname?: string; + levelActual: number; + moves: string[]; + rarity: Rarity; + restricted: boolean; +} + +function pushIssue(issues: PartyValidationIssue[], issue: PartyValidationIssue): void { + if (issues.some((existing) => existing.code === issue.code && existing.field === issue.field)) { + return; + } + + issues.push(issue); +} + +function normalizeNickname(nickname: string | undefined): string | undefined { + if (typeof nickname !== 'string') { + return undefined; + } + + const trimmed = nickname.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeMoves(moves: string[]): string[] { + return moves.map((move) => move.trim()); +} + +function hasDuplicateValues(values: string[]): boolean { + return new Set(values).size !== values.length; +} + +function validateCandidateMembers(input: ValidateOnlinePartyInput): { + issues: PartyValidationIssue[]; + members: NormalizedCandidateMember[]; +} { + const issues: PartyValidationIssue[] = []; + const ruleset = getRulesetByGeneration(input.generation); + const pokemonDb = getPokemonDB(input.generation); + const slotSet = new Set(); + const normalizedMembers: NormalizedCandidateMember[] = []; + + if (input.members.length !== ruleset.party.size) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_PARTY_SIZE_INVALID, + field: 'members', + meta: { expectedSize: ruleset.party.size, actualSize: input.members.length }, + }); + } + + for (const [index, member] of input.members.entries()) { + const fieldPrefix = `members[${index}]`; + let hasMemberIssue = false; + + if (!Number.isInteger(member.slot) || member.slot < 1 || member.slot > ruleset.party.size) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_PARTY_SLOT_INVALID, + field: `${fieldPrefix}.slot`, + meta: { slot: member.slot }, + }); + hasMemberIssue = true; + } + + if (!hasMemberIssue && slotSet.has(member.slot)) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_PARTY_SLOT_DUPLICATE, + field: `${fieldPrefix}.slot`, + meta: { slot: member.slot }, + }); + hasMemberIssue = true; + } + if (!hasMemberIssue) { + slotSet.add(member.slot); + } + + if (typeof member.pokemonInstanceId !== 'string' || member.pokemonInstanceId.trim().length === 0) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_PARTY_INSTANCE_ID_INVALID, + field: `${fieldPrefix}.pokemonInstanceId`, + }); + hasMemberIssue = true; + } + + if (!Number.isInteger(member.levelActual) || member.levelActual < 1) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_LEVEL_INVALID, + field: `${fieldPrefix}.levelActual`, + meta: { levelActual: member.levelActual }, + }); + hasMemberIssue = true; + } + + const parsedSpeciesId = Number(member.speciesId); + if (!Number.isInteger(parsedSpeciesId) || parsedSpeciesId < 1) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_SPECIES_INVALID, + field: `${fieldPrefix}.speciesId`, + meta: { speciesId: member.speciesId }, + }); + hasMemberIssue = true; + } + + const normalizedSpeciesId = String(parsedSpeciesId); + const generationBySpecies = Number.isInteger(parsedSpeciesId) + ? speciesIdToGeneration(parsedSpeciesId) + : undefined; + if ( + Number.isInteger(parsedSpeciesId) + && parsedSpeciesId >= 1 + && (generationBySpecies !== input.generation || !pokemonDb.pokemon[normalizedSpeciesId]) + ) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_SPECIES_UNKNOWN, + field: `${fieldPrefix}.speciesId`, + meta: { speciesId: member.speciesId, generation: input.generation }, + }); + hasMemberIssue = true; + } + + if (!Array.isArray(member.moves) || member.moves.length !== 4) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_MOVES_INVALID, + field: `${fieldPrefix}.moves`, + }); + hasMemberIssue = true; + } + + const moves = Array.isArray(member.moves) ? normalizeMoves(member.moves) : []; + if ( + Array.isArray(member.moves) + && (moves.some((move) => move.length === 0) || hasDuplicateValues(moves)) + ) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_MOVES_INVALID, + field: `${fieldPrefix}.moves`, + }); + hasMemberIssue = true; + } + + if (hasMemberIssue) { + continue; + } + + normalizedMembers.push({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId.trim(), + speciesId: normalizedSpeciesId, + nickname: normalizeNickname(member.nickname), + levelActual: member.levelActual, + moves, + rarity: pokemonDb.pokemon[normalizedSpeciesId].rarity, + restricted: isRestrictedSpecies(input.generation, normalizedSpeciesId), + }); + } + + const duplicateSpecies = normalizedMembers.length > 0 + && new Set(normalizedMembers.map((member) => member.speciesId)).size !== normalizedMembers.length; + if (duplicateSpecies) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_SPECIES_DUPLICATE, + field: 'members[].speciesId', + }); + } + + const legendaryMythicalCount = normalizedMembers.filter( + (member) => member.rarity === 'legendary' || member.rarity === 'mythical', + ).length; + if (legendaryMythicalCount > ruleset.specialLimits.legendaryMythicalTotal) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_SPECIAL_LIMIT_LEGENDARY_MYTHICAL_EXCEEDED, + field: 'members', + meta: { + limit: ruleset.specialLimits.legendaryMythicalTotal, + actualCount: legendaryMythicalCount, + }, + }); + } + + const restrictedCount = normalizedMembers.filter((member) => member.restricted).length; + if (restrictedCount > ruleset.specialLimits.restrictedTotal) { + pushIssue(issues, { + code: PARTY_VALIDATION_ERROR_CODES.PVP_SPECIAL_LIMIT_RESTRICTED_EXCEEDED, + field: 'members', + meta: { + limit: ruleset.specialLimits.restrictedTotal, + actualCount: restrictedCount, + }, + }); + } + + return { issues, members: normalizedMembers }; +} + +function toSnapshotMembers( + generation: ValidateOnlinePartyInput['generation'], + members: NormalizedCandidateMember[], +): OnlinePartyMemberSnapshotDraft[] { + return [...members] + .sort((left, right) => left.slot - right.slot) + .map((member) => ({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId, + speciesId: member.speciesId, + nickname: member.nickname, + levelActual: member.levelActual, + levelEffective: computeGenerationEffectiveLevel(member.levelActual, generation), + specialClass: { + legendary: member.rarity === 'legendary', + mythical: member.rarity === 'mythical', + restricted: member.restricted, + }, + moves: [...member.moves], + })); +} + +function toFailure(issues: PartyValidationIssue[]): PartyValidationResult { + return { + ok: false, + errorCodes: [...new Set(issues.map((issue) => issue.code))], + issues, + }; +} + +function buildSnapshotDraft(input: ValidateOnlinePartyInput, members: NormalizedCandidateMember[]): OnlinePartySnapshotDraft { + const ruleset = getRulesetByGeneration(input.generation); + const snapshotMembers = toSnapshotMembers(input.generation, members); + + return { + generation: input.generation, + rulesetKey: ruleset.rulesetKey, + validationStatus: 'accepted', + proofVersion: input.growthProof.proofVersion, + capturedAt: input.growthProof.capturedAt, + sourceSaveId: input.growthProof.sourceSaveId, + sourceSaveRevision: input.growthProof.sourceSaveRevision, + partySummary: { + memberCount: snapshotMembers.length, + legendaryMythicalCount: snapshotMembers.filter( + (member) => member.specialClass.legendary || member.specialClass.mythical, + ).length, + restrictedCount: snapshotMembers.filter((member) => member.specialClass.restricted).length, + speciesDupClause: ruleset.party.speciesDupClause, + }, + members: snapshotMembers, + }; +} + +export function validateOnlineParty(input: ValidateOnlinePartyInput): PartyValidationResult { + const candidateResult = validateCandidateMembers(input); + const growthProofResult = validateGrowthProof(input.growthProof, input.members); + const issues = [...candidateResult.issues, ...growthProofResult.issues]; + + if (issues.length > 0) { + return toFailure(issues); + } + + return { + ok: true, + snapshot: buildSnapshotDraft(input, candidateResult.members), + }; +} diff --git a/src/server/projection/battle-projection.ts b/src/server/projection/battle-projection.ts new file mode 100644 index 00000000..8b29ec41 --- /dev/null +++ b/src/server/projection/battle-projection.ts @@ -0,0 +1,64 @@ +import type { RoomSeat } from '../rooms/index.js'; +import { createViewerVisibleState } from '../battle/battle-event-log.js'; +import type { BattleDebugView, BattleRequestState, BattleSessionRecord, RoomSnapshotPayload } from '../battle/battle-types.js'; + +function getRemainingDeadlineMs(requestState: BattleRequestState | null, now: Date): number { + if (!requestState) { + return 0; + } + + return Math.max(0, new Date(requestState.deadlineAt).getTime() - now.getTime()); +} + +function hasSubmittedCurrentCommand(session: BattleSessionRecord, seat: RoomSeat): boolean { + if (!session.requestState) { + return false; + } + + if (session.requestState.kind === 'choose_move_or_switch') { + return Boolean(session.pendingCommands[seat]); + } + + return Boolean(session.pendingReplacementCommands[seat]); +} + +export function buildRoomSnapshotPayload( + session: BattleSessionRecord, + seat: RoomSeat, + now: Date, +): RoomSnapshotPayload { + const requestState = session.requestState?.requiredSeats.includes(seat) + ? session.requestState + : null; + + return { + roomStatus: session.roomStatus, + battleStatus: session.phase, + generation: session.generation, + rulesetKey: session.rulesetKey, + yourSeat: seat, + turn: session.turn, + visibleState: createViewerVisibleState(session, seat), + pendingRequest: requestState + ? { + kind: requestState.kind, + deadlineMs: getRemainingDeadlineMs(requestState, now), + commandSubmitted: hasSubmittedCurrentCommand(session, seat), + } + : null, + }; +} + +export function buildBattleDebugView(session: BattleSessionRecord): BattleDebugView { + return { + roomId: session.roomId, + battleId: session.battleId, + phase: session.phase, + turn: session.turn, + requestState: session.requestState ? structuredClone(session.requestState) : null, + commands: structuredClone(session.commandLog), + events: structuredClone(session.eventLog), + timeouts: structuredClone(session.timeoutState), + result: session.result ? structuredClone(session.result) : null, + }; +} diff --git a/src/server/projection/index.ts b/src/server/projection/index.ts new file mode 100644 index 00000000..80c493c5 --- /dev/null +++ b/src/server/projection/index.ts @@ -0,0 +1,2 @@ +export { projectRoomView, RoomProjectionError, type RoomView } from './room-projection.js'; +export { buildBattleDebugView, buildRoomSnapshotPayload } from './battle-projection.js'; diff --git a/src/server/projection/room-projection.ts b/src/server/projection/room-projection.ts new file mode 100644 index 00000000..a6c24445 --- /dev/null +++ b/src/server/projection/room-projection.ts @@ -0,0 +1,134 @@ +import type { + BattleFreezeStatus, + BattleRoomRecord, + RoomPlayerBinding, + RoomSeat, +} from '../rooms/index.js'; + +export interface RoomView { + room: { + roomId: string; + roomCode: string; + mode: BattleRoomRecord['room']['mode']; + status: BattleRoomRecord['room']['status']; + generation: BattleRoomRecord['room']['generation']; + rulesetKey: BattleRoomRecord['room']['rulesetKey']; + createdAt: string; + expiresAt: string | null; + }; + you: { + seat: RoomSeat; + partySnapshotId: string; + partyValidationStatus: RoomPlayerBinding['partyValidationStatus']; + presence: RoomPlayerBinding['presence']; + battleReady: boolean; + }; + opponent: { + seat: RoomSeat; + presence: RoomPlayerBinding['presence']; + battleReady: boolean; + displayName: string; + } | null; + match: { + freezeStatus: BattleFreezeStatus; + battleId: null; + battleStartedAt: string | null; + }; +} + +export class RoomProjectionError extends Error { + readonly status: number; + + readonly code: string; + + readonly retryable: boolean; + + readonly details?: Record; + + constructor(options: { + status: number; + code: string; + message: string; + retryable: boolean; + details?: Record; + }) { + super(options.message); + this.name = 'RoomProjectionError'; + this.status = options.status; + this.code = options.code; + this.retryable = options.retryable; + this.details = options.details; + } +} + +function toDisplayName(userId: string): string { + return userId; +} + +function resolveViewerBindings(room: BattleRoomRecord, viewerId: string): { + you: RoomPlayerBinding; + opponent: RoomPlayerBinding | null; +} { + if (room.host.userId === viewerId) { + return { + you: room.host, + opponent: room.guest, + }; + } + + if (room.guest?.userId === viewerId) { + return { + you: room.guest, + opponent: room.host, + }; + } + + throw new RoomProjectionError({ + status: 403, + code: 'PVP_ROOM_ACCESS_DENIED', + message: 'Only room participants can view this PvP room.', + retryable: false, + details: { roomId: room.room.roomId }, + }); +} + +function resolveFreezeStatus(room: BattleRoomRecord): BattleFreezeStatus { + return room.battleFreeze?.freezeStatus ?? 'waiting_for_opponent'; +} + +export function projectRoomView(room: BattleRoomRecord, viewerId: string): RoomView { + const { you, opponent } = resolveViewerBindings(room, viewerId); + + return { + room: { + roomId: room.room.roomId, + roomCode: room.room.roomCode, + mode: room.room.mode, + status: room.room.status, + generation: room.room.generation, + rulesetKey: room.room.rulesetKey, + createdAt: room.room.createdAt, + expiresAt: room.room.expiresAt, + }, + you: { + seat: you.seat, + partySnapshotId: you.partySnapshotId, + partyValidationStatus: you.partyValidationStatus, + presence: you.presence, + battleReady: you.battleReady, + }, + opponent: opponent + ? { + seat: opponent.seat, + presence: opponent.presence, + battleReady: opponent.battleReady, + displayName: toDisplayName(opponent.userId), + } + : null, + match: { + freezeStatus: resolveFreezeStatus(room), + battleId: null, + battleStartedAt: room.room.startedAt, + }, + }; +} diff --git a/src/server/rooms/index.ts b/src/server/rooms/index.ts new file mode 100644 index 00000000..7c9fc51b --- /dev/null +++ b/src/server/rooms/index.ts @@ -0,0 +1,31 @@ +export { createRoomCode, isRoomCodeFormat, normalizeRoomCode } from './room-code.js'; +export { InMemoryRoomRepository, type RoomRepository } from './room-repository.js'; +export { RoomService, RoomServiceError, type RoomServiceOptions } from './room-service.js'; +export type { + BattleFreezeSnapshot, + BattleFreezeStatus, + BattleRoomMode, + BattleRoomRecord, + BattleRoomStatus, + BattleRoomVisibility, + CreateRoomInput, + JoinRoomInput, + RoomPlayerBinding, + RoomPresence, + RoomSeat, + RoomSummary, +} from './room-types.js'; +export { + isParticipationLockedStatus, + isSupportedRoomVisibility, + type RoomValidationFailure, + validateCreateBindingAvailability, + validateGuestRuleset, + validateJoinGeneration, + validateJoinRequestCode, + validateJoinRoomState, + validatePartyAccepted, + validateRequestedRuleset, + validateRequestedVisibility, + validateSelfJoin, +} from './room-validator.js'; diff --git a/src/server/rooms/room-code.ts b/src/server/rooms/room-code.ts new file mode 100644 index 00000000..b8f3668b --- /dev/null +++ b/src/server/rooms/room-code.ts @@ -0,0 +1,30 @@ +import { randomInt } from 'node:crypto'; + +const ROOM_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const ROOM_CODE_LENGTH = 6; + +export function normalizeRoomCode(roomCode: string): string { + return roomCode.trim().toUpperCase(); +} + +export function isRoomCodeFormat(roomCode: string): boolean { + return /^[A-Z2-9]{6}$/.test(normalizeRoomCode(roomCode)); +} + +function defaultIndexGenerator(): number { + return randomInt(ROOM_CODE_ALPHABET.length); +} + +export function createRoomCode(nextIndex: () => number = defaultIndexGenerator): string { + let roomCode = ''; + + for (let index = 0; index < ROOM_CODE_LENGTH; index += 1) { + const rawIndex = nextIndex(); + const normalizedIndex = Number.isInteger(rawIndex) + ? Math.abs(rawIndex) % ROOM_CODE_ALPHABET.length + : Math.floor(Math.abs(rawIndex) * ROOM_CODE_ALPHABET.length) % ROOM_CODE_ALPHABET.length; + roomCode += ROOM_CODE_ALPHABET[normalizedIndex] ?? ROOM_CODE_ALPHABET[0]; + } + + return roomCode; +} diff --git a/src/server/rooms/room-repository.ts b/src/server/rooms/room-repository.ts new file mode 100644 index 00000000..f78aff3a --- /dev/null +++ b/src/server/rooms/room-repository.ts @@ -0,0 +1,93 @@ +import type { BattleRoomRecord } from './room-types.js'; +import { isParticipationLockedStatus } from './room-validator.js'; + +export interface RoomRepository { + getRoom(roomId: string): BattleRoomRecord | undefined; + findActiveRoomByPlayerId(playerId: string): BattleRoomRecord | undefined; + hasRoomCode(roomCode: string): boolean; + saveRoom(room: BattleRoomRecord): void; + createRoomId(): string; + seedRooms(rooms: readonly BattleRoomRecord[]): void; +} + +function cloneRoom(room: BattleRoomRecord): BattleRoomRecord { + return structuredClone(room); +} + +function isPlayerBound(room: BattleRoomRecord, playerId: string): boolean { + return room.host.userId === playerId || room.guest?.userId === playerId; +} + +function extractRoomSequence(roomId: string): number | undefined { + const matched = /^room_(\d{6})$/.exec(roomId); + if (!matched) { + return undefined; + } + + return Number(matched[1]); +} + +export class InMemoryRoomRepository implements RoomRepository { + private readonly roomsById = new Map(); + + private roomSequence = 0; + + getRoom(roomId: string): BattleRoomRecord | undefined { + const room = this.roomsById.get(roomId); + return room ? cloneRoom(room) : undefined; + } + + findActiveRoomByPlayerId(playerId: string): BattleRoomRecord | undefined { + for (const room of this.roomsById.values()) { + if (!isPlayerBound(room, playerId)) { + continue; + } + + if (!isParticipationLockedStatus(room.room.status)) { + continue; + } + + return cloneRoom(room); + } + + return undefined; + } + + hasRoomCode(roomCode: string): boolean { + for (const room of this.roomsById.values()) { + if (room.room.roomCode === roomCode) { + return true; + } + } + + return false; + } + + saveRoom(room: BattleRoomRecord): void { + this.roomsById.set(room.room.roomId, cloneRoom(room)); + this.syncSequence(room.room.roomId); + } + + createRoomId(): string { + this.roomSequence += 1; + return `room_${String(this.roomSequence).padStart(6, '0')}`; + } + + seedRooms(rooms: readonly BattleRoomRecord[]): void { + for (const room of rooms) { + this.roomsById.set(room.room.roomId, cloneRoom(room)); + this.syncSequence(room.room.roomId); + } + } + + private syncSequence(roomId: string): void { + const sequence = extractRoomSequence(roomId); + if (!sequence) { + return; + } + + if (sequence > this.roomSequence) { + this.roomSequence = sequence; + } + } +} diff --git a/src/server/rooms/room-service.ts b/src/server/rooms/room-service.ts new file mode 100644 index 00000000..69de0ba3 --- /dev/null +++ b/src/server/rooms/room-service.ts @@ -0,0 +1,425 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { + PartyRegistrationService, + PartyRegistrationServiceError, + type ActivePartySnapshot, +} from '../parties/index.js'; +import { getRulesetByGeneration, type RulesetSummary } from '../rules/index.js'; +import { createRoomCode, normalizeRoomCode } from './room-code.js'; +import { InMemoryRoomRepository, type RoomRepository } from './room-repository.js'; +import type { + BattleFreezeSnapshot, + BattleRoomRecord, + CreateRoomInput, + JoinRoomInput, + RoomPlayerBinding, +} from './room-types.js'; +import { + type RoomValidationFailure, + validateCreateBindingAvailability, + validateGuestRuleset, + validateJoinGeneration, + validateJoinRequestCode, + validateJoinRoomState, + validatePartyAccepted, + validateRequestedRuleset, + validateRequestedVisibility, + validateSelfJoin, +} from './room-validator.js'; + +const DEFAULT_ROOM_TTL_MS = 15 * 60 * 1000; +const MAX_ROOM_CODE_ATTEMPTS = 32; + +export interface RoomServiceOptions { + repository?: RoomRepository; + partyService?: PartyRegistrationService; + now?: () => Date; + roomCodeGenerator?: () => string; + battleSeedGenerator?: () => string; + roomTtlMs?: number; +} + +interface ServiceErrorOptions { + status: number; + code: string; + message: string; + retryable: boolean; + details?: Record; +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(',')}]`; + } + + const entries = Object.entries(value as Record).sort(([left], [right]) => + left.localeCompare(right), + ); + + return `{${entries + .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`) + .join(',')}}`; +} + +function cloneRoom(room: BattleRoomRecord): BattleRoomRecord { + return structuredClone(room); +} + +function toIsoString(value: Date): string { + return value.toISOString(); +} + +function withAddedMs(value: Date, milliseconds: number): Date { + return new Date(value.getTime() + milliseconds); +} + +function createRulesetHash(ruleset: RulesetSummary): string { + return `sha256:${createHash('sha256').update(stableStringify(ruleset)).digest('hex')}`; +} + +function createDefaultBattleSeed(): string { + return `bseed_${randomBytes(8).toString('hex')}`; +} + +function createRoomBinding(seat: RoomPlayerBinding['seat'], party: ActivePartySnapshot, joinedAt: string): RoomPlayerBinding { + return { + seat, + userId: party.playerId, + partySnapshotId: party.snapshotId, + partySnapshotVersion: party.snapshotVersion, + partyValidationStatus: party.validationStatus, + presence: 'offline', + joinedAt, + battleReady: false, + }; +} + +function throwFailure(failure: RoomValidationFailure): never { + throw new RoomServiceError(failure); +} + +export class RoomServiceError extends Error { + readonly status: number; + + readonly code: string; + + readonly retryable: boolean; + + readonly details?: Record; + + constructor(options: ServiceErrorOptions) { + super(options.message); + this.name = 'RoomServiceError'; + this.status = options.status; + this.code = options.code; + this.retryable = options.retryable; + this.details = options.details; + } +} + +export class RoomService { + private readonly repository: RoomRepository; + + private readonly partyService: PartyRegistrationService; + + private readonly now: () => Date; + + private readonly roomCodeGenerator: () => string; + + private readonly battleSeedGenerator: () => string; + + private readonly roomTtlMs: number; + + constructor(options: RoomServiceOptions = {}) { + this.repository = options.repository ?? new InMemoryRoomRepository(); + this.partyService = options.partyService ?? new PartyRegistrationService(); + this.now = options.now ?? (() => new Date()); + this.roomCodeGenerator = options.roomCodeGenerator ?? (() => createRoomCode()); + this.battleSeedGenerator = options.battleSeedGenerator ?? createDefaultBattleSeed; + this.roomTtlMs = options.roomTtlMs ?? DEFAULT_ROOM_TTL_MS; + } + + getRoom(roomId: string): BattleRoomRecord { + const room = this.repository.getRoom(roomId); + if (!room) { + throw new RoomServiceError({ + status: 404, + code: 'PVP_ROOM_NOT_FOUND', + message: 'The requested PvP room does not exist.', + retryable: false, + details: { roomId }, + }); + } + + return cloneRoom(room); + } + + createRoom(input: CreateRoomInput): BattleRoomRecord { + const ruleset = this.resolveRuleset(input.generation); + const visibilityFailure = validateRequestedVisibility(input.visibility); + if (visibilityFailure) { + throwFailure(visibilityFailure); + } + + const requestedRulesetFailure = validateRequestedRuleset(input.rulesetKey, ruleset); + if (requestedRulesetFailure) { + throwFailure(requestedRulesetFailure); + } + + const alreadyBoundRoom = this.repository.findActiveRoomByPlayerId(input.playerId); + const bindingFailure = validateCreateBindingAvailability(input.playerId, alreadyBoundRoom); + if (bindingFailure) { + throwFailure(bindingFailure); + } + + const activeParty = this.getBindableActiveParty({ + playerId: input.playerId, + generation: ruleset.generation, + missingPartyCode: 'PVP_PARTY_NOT_REGISTERED', + missingPartyMessage: 'Register an active online party before creating a PvP room.', + mismatchCode: 'PVP_RULESET_MISMATCH', + mismatchMessage: 'The active online party does not match the current PvP ruleset.', + details: { generation: ruleset.generation }, + }); + + const partyAcceptedFailure = validatePartyAccepted(activeParty, { + generation: ruleset.generation, + snapshotId: activeParty.snapshotId, + }); + if (partyAcceptedFailure) { + throwFailure(partyAcceptedFailure); + } + + const createdAt = this.now(); + const createdAtIso = toIsoString(createdAt); + const expiresAtIso = toIsoString(withAddedMs(createdAt, this.roomTtlMs)); + const roomCode = this.createUniqueRoomCode(); + const roomId = this.repository.createRoomId(); + const hostBinding = createRoomBinding('host', activeParty, createdAtIso); + const room: BattleRoomRecord = { + room: { + roomId, + roomCode, + mode: 'friendly_private', + visibility: 'private_friend', + status: 'waiting_for_opponent', + generation: ruleset.generation, + rulesetKey: ruleset.rulesetKey, + createdByUserId: input.playerId, + createdAt: createdAtIso, + expiresAt: expiresAtIso, + startedAt: null, + finishedAt: null, + cancelledAt: null, + }, + host: hostBinding, + guest: null, + rulesetSnapshot: structuredClone(ruleset), + battleFreeze: null, + }; + + this.repository.saveRoom(room); + return cloneRoom(room); + } + + joinRoom(input: JoinRoomInput): BattleRoomRecord { + const room = this.repository.getRoom(input.roomId); + if (!room) { + throw new RoomServiceError({ + status: 404, + code: 'PVP_ROOM_NOT_FOUND', + message: 'The requested PvP room does not exist.', + retryable: false, + details: { roomId: input.roomId }, + }); + } + + const stateFailure = validateJoinRoomState(room); + if (stateFailure) { + throwFailure(stateFailure); + } + + const normalizedRoomCode = normalizeRoomCode(input.roomCode); + const roomCodeFailure = validateJoinRequestCode(room, normalizedRoomCode); + if (roomCodeFailure) { + throwFailure(roomCodeFailure); + } + + const generationFailure = validateJoinGeneration(room, input.generation); + if (generationFailure) { + throwFailure(generationFailure); + } + + const selfJoinFailure = validateSelfJoin(room, input.playerId); + if (selfJoinFailure) { + throwFailure(selfJoinFailure); + } + + const alreadyBoundRoom = this.repository.findActiveRoomByPlayerId(input.playerId); + if (alreadyBoundRoom && alreadyBoundRoom.room.roomId !== room.room.roomId) { + const bindingFailure = validateCreateBindingAvailability(input.playerId, alreadyBoundRoom); + if (bindingFailure) { + throwFailure(bindingFailure); + } + } + + const activeParty = this.getBindableActiveParty({ + playerId: input.playerId, + generation: room.room.generation, + missingPartyCode: 'PVP_PARTY_NOT_REGISTERED', + missingPartyMessage: 'Register an active online party before joining a PvP room.', + mismatchCode: 'PVP_ROOM_RULESET_MISMATCH', + mismatchMessage: 'The active online party does not match the PvP room ruleset.', + details: { + roomId: room.room.roomId, + generation: room.room.generation, + roomRulesetKey: room.room.rulesetKey, + }, + }); + + const guestRulesetFailure = validateGuestRuleset(room, activeParty); + if (guestRulesetFailure) { + throwFailure(guestRulesetFailure); + } + + const partyAcceptedFailure = validatePartyAccepted(activeParty, { + roomId: room.room.roomId, + generation: room.room.generation, + snapshotId: activeParty.snapshotId, + }); + if (partyAcceptedFailure) { + throwFailure(partyAcceptedFailure); + } + + const joinedAt = toIsoString(this.now()); + const guestBinding = createRoomBinding('guest', activeParty, joinedAt); + const battleFreeze = this.prepareBattleFreeze(room, activeParty, joinedAt); + const nextRoom: BattleRoomRecord = { + ...room, + room: { + ...room.room, + status: 'awaiting_presence', + expiresAt: null, + }, + host: { + ...room.host, + battleReady: true, + }, + guest: { + ...guestBinding, + battleReady: true, + }, + battleFreeze, + }; + + this.repository.saveRoom(nextRoom); + return cloneRoom(nextRoom); + } + + private getBindableActiveParty(options: { + playerId: string; + generation: string; + missingPartyCode: string; + missingPartyMessage: string; + mismatchCode: string; + mismatchMessage: string; + details: Record; + }): ActivePartySnapshot { + try { + return this.partyService.getActiveParty({ + playerId: options.playerId, + generation: options.generation, + }); + } catch (error: unknown) { + if (!(error instanceof PartyRegistrationServiceError)) { + throw error; + } + + if (error.code === 'PVP_ACTIVE_PARTY_NOT_FOUND') { + throw new RoomServiceError({ + status: 404, + code: options.missingPartyCode, + message: options.missingPartyMessage, + retryable: false, + details: options.details, + }); + } + + if (error.code === 'PVP_RULESET_MISMATCH') { + throw new RoomServiceError({ + status: 409, + code: options.mismatchCode, + message: options.mismatchMessage, + retryable: true, + details: { + ...options.details, + ...error.details, + }, + }); + } + + throw new RoomServiceError({ + status: error.status, + code: error.code, + message: error.message, + retryable: error.retryable, + details: error.details, + }); + } + } + + private resolveRuleset(generation: string): RulesetSummary { + try { + return structuredClone(getRulesetByGeneration(generation)); + } catch { + throw new RoomServiceError({ + status: 404, + code: 'PVP_RULESET_NOT_FOUND', + message: 'No active PvP ruleset is configured for this generation.', + retryable: false, + details: { generation }, + }); + } + } + + private createUniqueRoomCode(): string { + for (let attempt = 0; attempt < MAX_ROOM_CODE_ATTEMPTS; attempt += 1) { + const roomCode = normalizeRoomCode(this.roomCodeGenerator()); + if (!this.repository.hasRoomCode(roomCode)) { + return roomCode; + } + } + + throw new RoomServiceError({ + status: 503, + code: 'PVP_ROOM_CODE_UNAVAILABLE', + message: 'Unable to allocate a unique PvP room code at the moment.', + retryable: true, + }); + } + + private prepareBattleFreeze( + room: BattleRoomRecord, + guestParty: ActivePartySnapshot, + preparedAt: string, + ): BattleFreezeSnapshot { + return { + freezeStatus: 'pending_presence', + preparedAt, + generation: room.room.generation, + rulesetKey: room.room.rulesetKey, + rulesetHash: createRulesetHash(room.rulesetSnapshot), + rulesetSnapshot: structuredClone(room.rulesetSnapshot), + hostPartySnapshotId: room.host.partySnapshotId, + hostPartySnapshotVersion: room.host.partySnapshotVersion, + guestPartySnapshotId: guestParty.snapshotId, + guestPartySnapshotVersion: guestParty.snapshotVersion, + battleSeed: this.battleSeedGenerator(), + }; + } +} diff --git a/src/server/rooms/room-types.ts b/src/server/rooms/room-types.ts new file mode 100644 index 00000000..edfe90fa --- /dev/null +++ b/src/server/rooms/room-types.ts @@ -0,0 +1,78 @@ +import type { ActivePartySnapshot } from '../parties/index.js'; +import type { PvpGeneration, RulesetKey, RulesetSummary } from '../rules/index.js'; + +export type BattleRoomMode = 'friendly_private'; +export type BattleRoomVisibility = 'private_friend'; +export type BattleRoomStatus = + | 'waiting_for_opponent' + | 'awaiting_presence' + | 'starting' + | 'in_progress' + | 'finished' + | 'cancelled'; +export type RoomSeat = 'host' | 'guest'; +export type RoomPresence = 'offline' | 'connected' | 'disconnected'; +export type BattleFreezeStatus = 'waiting_for_opponent' | 'pending_presence'; + +export interface RoomSummary { + roomId: string; + roomCode: string; + mode: BattleRoomMode; + visibility: BattleRoomVisibility; + status: BattleRoomStatus; + generation: PvpGeneration; + rulesetKey: RulesetKey; + createdByUserId: string; + createdAt: string; + expiresAt: string | null; + startedAt: string | null; + finishedAt: string | null; + cancelledAt: string | null; +} + +export interface RoomPlayerBinding { + seat: RoomSeat; + userId: string; + partySnapshotId: string; + partySnapshotVersion: number; + partyValidationStatus: ActivePartySnapshot['validationStatus']; + presence: RoomPresence; + joinedAt: string; + battleReady: boolean; +} + +export interface BattleFreezeSnapshot { + freezeStatus: Extract; + preparedAt: string; + generation: PvpGeneration; + rulesetKey: RulesetKey; + rulesetHash: string; + rulesetSnapshot: RulesetSummary; + hostPartySnapshotId: string; + hostPartySnapshotVersion: number; + guestPartySnapshotId: string; + guestPartySnapshotVersion: number; + battleSeed: string; +} + +export interface BattleRoomRecord { + room: RoomSummary; + host: RoomPlayerBinding; + guest: RoomPlayerBinding | null; + rulesetSnapshot: RulesetSummary; + battleFreeze: BattleFreezeSnapshot | null; +} + +export interface CreateRoomInput { + playerId: string; + generation: string; + visibility: string; + rulesetKey?: string; +} + +export interface JoinRoomInput { + playerId: string; + roomId: string; + roomCode: string; + generation: string; +} diff --git a/src/server/rooms/room-validator.ts b/src/server/rooms/room-validator.ts new file mode 100644 index 00000000..ac8eb313 --- /dev/null +++ b/src/server/rooms/room-validator.ts @@ -0,0 +1,225 @@ +import type { ActivePartySnapshot } from '../parties/index.js'; +import type { RulesetSummary } from '../rules/index.js'; +import type { + BattleRoomRecord, + BattleRoomStatus, + BattleRoomVisibility, +} from './room-types.js'; + +export interface RoomValidationFailure { + status: number; + code: string; + message: string; + retryable: boolean; + details?: Record; +} + +function createFailure(options: RoomValidationFailure): RoomValidationFailure { + return options; +} + +export function isSupportedRoomVisibility(value: string): value is BattleRoomVisibility { + return value === 'private_friend'; +} + +export function isParticipationLockedStatus(status: BattleRoomStatus): boolean { + return ( + status === 'waiting_for_opponent' || + status === 'awaiting_presence' || + status === 'starting' || + status === 'in_progress' + ); +} + +export function validateRequestedVisibility(visibility: string): RoomValidationFailure | undefined { + if (isSupportedRoomVisibility(visibility)) { + return undefined; + } + + return createFailure({ + status: 422, + code: 'PVP_ROOM_VISIBILITY_INVALID', + message: 'Only private friend rooms are supported in the current PvP phase.', + retryable: false, + details: { visibility }, + }); +} + +export function validateRequestedRuleset( + requestedRulesetKey: string | undefined, + activeRuleset: RulesetSummary, +): RoomValidationFailure | undefined { + if (!requestedRulesetKey || requestedRulesetKey === activeRuleset.rulesetKey) { + return undefined; + } + + return createFailure({ + status: 409, + code: 'PVP_RULESET_MISMATCH', + message: 'The requested room ruleset does not match the current active PvP ruleset.', + retryable: true, + details: { + generation: activeRuleset.generation, + requestedRulesetKey, + activeRulesetKey: activeRuleset.rulesetKey, + }, + }); +} + +export function validatePartyAccepted( + party: ActivePartySnapshot, + details: Record, +): RoomValidationFailure | undefined { + if (party.validationStatus === 'accepted') { + return undefined; + } + + return createFailure({ + status: 409, + code: 'PVP_PARTY_VALIDATION_REJECTED', + message: 'The active online party is not accepted for PvP room participation.', + retryable: false, + details, + }); +} + +export function validateCreateBindingAvailability( + playerId: string, + existingRoom: BattleRoomRecord | undefined, +): RoomValidationFailure | undefined { + if (!existingRoom) { + return undefined; + } + + const existingSeat = + existingRoom.host.userId === playerId + ? existingRoom.host.seat + : existingRoom.guest?.userId === playerId + ? existingRoom.guest.seat + : undefined; + + return createFailure({ + status: 409, + code: 'PVP_ROOM_ALREADY_BOUND', + message: 'The player is already bound to another active PvP room.', + retryable: false, + details: { + roomId: existingRoom.room.roomId, + status: existingRoom.room.status, + seat: existingSeat, + generation: existingRoom.room.generation, + }, + }); +} + +export function validateJoinRoomState(room: BattleRoomRecord): RoomValidationFailure | undefined { + if (room.guest) { + return createFailure({ + status: 409, + code: 'PVP_ROOM_ALREADY_FILLED', + message: 'The PvP room already has both host and guest bound.', + retryable: false, + details: { roomId: room.room.roomId, status: room.room.status }, + }); + } + + if (room.room.status !== 'waiting_for_opponent') { + return createFailure({ + status: 409, + code: 'PVP_ROOM_STATE_INVALID', + message: 'The PvP room is not accepting opponents in its current state.', + retryable: false, + details: { roomId: room.room.roomId, status: room.room.status }, + }); + } + + return undefined; +} + +export function validateJoinRequestCode( + room: BattleRoomRecord, + requestedRoomCode: string, +): RoomValidationFailure | undefined { + if (room.room.roomCode === requestedRoomCode) { + return undefined; + } + + return createFailure({ + status: 409, + code: 'PVP_ROOM_CODE_MISMATCH', + message: 'The supplied room code does not match the target PvP room.', + retryable: false, + details: { roomId: room.room.roomId }, + }); +} + +export function validateJoinGeneration( + room: BattleRoomRecord, + requestedGeneration: string, +): RoomValidationFailure | undefined { + if (requestedGeneration === room.room.generation) { + return undefined; + } + + return createFailure({ + status: 409, + code: 'PVP_ROOM_GENERATION_MISMATCH', + message: 'The supplied generation does not match the PvP room generation.', + retryable: false, + details: { + roomId: room.room.roomId, + requestedGeneration, + roomGeneration: room.room.generation, + }, + }); +} + +export function validateSelfJoin(room: BattleRoomRecord, playerId: string): RoomValidationFailure | undefined { + if (room.host.userId !== playerId) { + return undefined; + } + + return createFailure({ + status: 409, + code: 'PVP_ROOM_SELF_JOIN_FORBIDDEN', + message: 'The host cannot join their own PvP room as the opponent.', + retryable: false, + details: { roomId: room.room.roomId }, + }); +} + +export function validateGuestRuleset( + room: BattleRoomRecord, + party: ActivePartySnapshot, +): RoomValidationFailure | undefined { + if (party.generation !== room.room.generation) { + return createFailure({ + status: 409, + code: 'PVP_ROOM_GENERATION_MISMATCH', + message: 'The guest active party generation does not match the PvP room.', + retryable: false, + details: { + roomId: room.room.roomId, + roomGeneration: room.room.generation, + partyGeneration: party.generation, + }, + }); + } + + if (party.rulesetKey === room.room.rulesetKey) { + return undefined; + } + + return createFailure({ + status: 409, + code: 'PVP_ROOM_RULESET_MISMATCH', + message: 'The guest active party ruleset does not match the PvP room ruleset.', + retryable: true, + details: { + roomId: room.room.roomId, + roomRulesetKey: room.room.rulesetKey, + partyRulesetKey: party.rulesetKey, + snapshotId: party.snapshotId, + }, + }); +} diff --git a/src/server/rules/index.ts b/src/server/rules/index.ts new file mode 100644 index 00000000..350643f2 --- /dev/null +++ b/src/server/rules/index.ts @@ -0,0 +1,25 @@ +export { computeEffectiveLevel, softCapAfter50V1 } from './level-compression.js'; +export { getRestrictedSpeciesSeed } from './restricted-species.js'; +export { createStaticRulesetRepository, StaticRulesetRepository } from './ruleset-repository.js'; +export { + computeGenerationEffectiveLevel, + getRestrictedSpeciesIds, + getRulesetByGeneration, + isRestrictedSpecies, + RulesetService, +} from './ruleset-service.js'; +export type { + BattlePolicy, + CheatPolicy, + EffectiveFormulaKey, + LevelDisplayMode, + LevelPolicy, + PartyPolicy, + PvpGeneration, + RulesetKey, + RulesetRepository, + RulesetStatus, + RulesetSummary, + SpecialLimits, +} from './ruleset-types.js'; +export { isPvpGeneration, SUPPORTED_PVP_GENERATIONS } from './ruleset-types.js'; diff --git a/src/server/rules/level-compression.ts b/src/server/rules/level-compression.ts new file mode 100644 index 00000000..d0cca10b --- /dev/null +++ b/src/server/rules/level-compression.ts @@ -0,0 +1,45 @@ +import type { LevelPolicy } from './ruleset-types.js'; + +export const DEFAULT_LEVEL_POLICY: LevelPolicy = { + displayMode: 'actual-level-visible', + effectiveFormulaKey: 'soft-cap-after-50-v1', + softCapStartsAt: 50, + effectiveLevelCap: 60, +}; + +function normalizeActualLevel(actualLevel: number): number { + if (!Number.isFinite(actualLevel) || !Number.isInteger(actualLevel) || actualLevel < 1) { + throw new Error(`Actual level must be a positive integer: ${actualLevel}`); + } + + return actualLevel; +} + +export function softCapAfter50V1( + actualLevel: number, + softCapStartsAt = DEFAULT_LEVEL_POLICY.softCapStartsAt, + effectiveLevelCap = DEFAULT_LEVEL_POLICY.effectiveLevelCap, +): number { + const normalizedLevel = normalizeActualLevel(actualLevel); + + if (normalizedLevel <= softCapStartsAt) { + return Math.min(normalizedLevel, effectiveLevelCap); + } + + const compressedLevel = softCapStartsAt + Math.ceil((normalizedLevel - softCapStartsAt) / 5); + return Math.min(compressedLevel, effectiveLevelCap); +} + +export function computeEffectiveLevel( + actualLevel: number, + levelPolicy: LevelPolicy = DEFAULT_LEVEL_POLICY, +): number { + switch (levelPolicy.effectiveFormulaKey) { + case 'soft-cap-after-50-v1': + return softCapAfter50V1(actualLevel, levelPolicy.softCapStartsAt, levelPolicy.effectiveLevelCap); + default: { + const exhaustiveCheck: never = levelPolicy.effectiveFormulaKey; + throw new Error(`Unsupported effective level formula: ${exhaustiveCheck}`); + } + } +} diff --git a/src/server/rules/restricted-species.ts b/src/server/rules/restricted-species.ts new file mode 100644 index 00000000..63b336c8 --- /dev/null +++ b/src/server/rules/restricted-species.ts @@ -0,0 +1,17 @@ +import type { PvpGeneration } from './ruleset-types.js'; + +const RESTRICTED_SPECIES_BY_GENERATION: Record = { + gen1: [150], + gen2: [249, 250], + gen3: [382, 383, 384], + gen4: [483, 484, 486, 487, 493], + gen5: [643, 644, 646], + gen6: [716, 717], + gen7: [791, 792, 800], + gen8: [888, 889, 890], + gen9: [1007, 1008], +}; + +export function getRestrictedSpeciesSeed(generation: PvpGeneration): readonly number[] { + return RESTRICTED_SPECIES_BY_GENERATION[generation]; +} diff --git a/src/server/rules/ruleset-repository.ts b/src/server/rules/ruleset-repository.ts new file mode 100644 index 00000000..85efe684 --- /dev/null +++ b/src/server/rules/ruleset-repository.ts @@ -0,0 +1,86 @@ +import { DEFAULT_LEVEL_POLICY } from './level-compression.js'; +import { getRestrictedSpeciesSeed } from './restricted-species.js'; +import type { + BattlePolicy, + CheatPolicy, + PartyPolicy, + PvpGeneration, + RulesetKey, + RulesetRepository, + RulesetSummary, + SpecialLimits, +} from './ruleset-types.js'; + +const UPDATED_AT = '2026-04-11T06:00:00Z'; + +const DEFAULT_PARTY_POLICY: PartyPolicy = { + size: 6, + activePartySlotsPerPlayer: 1, + speciesDupClause: true, +}; + +const DEFAULT_SPECIAL_LIMITS: SpecialLimits = { + legendaryMythicalTotal: 2, + restrictedTotal: 1, +}; + +const DEFAULT_BATTLE_POLICY: BattlePolicy = { + format: 'single', + teamPreview: false, + leadSelection: 'slot1_auto', + replacementSelection: 'manual', + actionTimeoutSeconds: 45, +}; + +const DEFAULT_CHEAT_POLICY: CheatPolicy = { + requireCleanSave: true, + allowCheatFlaggedSave: false, + growthSnapshotRequired: true, +}; + +function createRulesetSummary(generation: PvpGeneration): RulesetSummary { + return { + generation, + rulesetKey: `tkm-friendly-${generation}-v1`, + status: 'active', + party: DEFAULT_PARTY_POLICY, + specialLimits: DEFAULT_SPECIAL_LIMITS, + levelPolicy: DEFAULT_LEVEL_POLICY, + battlePolicy: DEFAULT_BATTLE_POLICY, + cheatPolicy: DEFAULT_CHEAT_POLICY, + updatedAt: UPDATED_AT, + }; +} + +const ACTIVE_RULESETS: Record = { + gen1: createRulesetSummary('gen1'), + gen2: createRulesetSummary('gen2'), + gen3: createRulesetSummary('gen3'), + gen4: createRulesetSummary('gen4'), + gen5: createRulesetSummary('gen5'), + gen6: createRulesetSummary('gen6'), + gen7: createRulesetSummary('gen7'), + gen8: createRulesetSummary('gen8'), + gen9: createRulesetSummary('gen9'), +}; + +const RESTRICTED_SPECIES_BY_RULESET_KEY = Object.fromEntries( + (Object.keys(ACTIVE_RULESETS) as PvpGeneration[]).map((generation) => [ + ACTIVE_RULESETS[generation].rulesetKey, + getRestrictedSpeciesSeed(generation), + ]), +) as Record; + +export class StaticRulesetRepository implements RulesetRepository { + getActiveRulesetByGeneration(generation: PvpGeneration): RulesetSummary | undefined { + return ACTIVE_RULESETS[generation]; + } + + getRestrictedSpeciesIdsByRulesetKey(rulesetKey: RulesetKey): readonly number[] | undefined { + return RESTRICTED_SPECIES_BY_RULESET_KEY[rulesetKey]; + } +} + +export function createStaticRulesetRepository(): RulesetRepository { + return new StaticRulesetRepository(); +} diff --git a/src/server/rules/ruleset-service.ts b/src/server/rules/ruleset-service.ts new file mode 100644 index 00000000..df27db25 --- /dev/null +++ b/src/server/rules/ruleset-service.ts @@ -0,0 +1,70 @@ +import { computeEffectiveLevel as computeEffectiveLevelWithPolicy } from './level-compression.js'; +import { createStaticRulesetRepository } from './ruleset-repository.js'; +import { isPvpGeneration } from './ruleset-types.js'; +import type { PvpGeneration, RulesetRepository, RulesetSummary } from './ruleset-types.js'; + +function parseGeneration(generation: string): PvpGeneration { + if (!isPvpGeneration(generation)) { + throw new Error(`Unsupported PvP generation: ${generation}`); + } + + return generation; +} + +function normalizeSpeciesId(speciesId: number | string): number { + const parsedSpeciesId = typeof speciesId === 'string' ? Number(speciesId) : speciesId; + + if (!Number.isInteger(parsedSpeciesId) || parsedSpeciesId < 1) { + throw new Error(`Species ID must be a positive integer: ${speciesId}`); + } + + return parsedSpeciesId; +} + +export class RulesetService { + constructor(private readonly repository: RulesetRepository = createStaticRulesetRepository()) {} + + getRulesetByGeneration(generation: string): RulesetSummary { + const parsedGeneration = parseGeneration(generation); + const ruleset = this.repository.getActiveRulesetByGeneration(parsedGeneration); + + if (!ruleset) { + throw new Error(`No active PvP ruleset configured for generation: ${generation}`); + } + + return ruleset; + } + + getRestrictedSpeciesIds(generation: string): readonly number[] { + const ruleset = this.getRulesetByGeneration(generation); + return this.repository.getRestrictedSpeciesIdsByRulesetKey(ruleset.rulesetKey) ?? []; + } + + isRestrictedSpecies(generation: string, speciesId: number | string): boolean { + const normalizedSpeciesId = normalizeSpeciesId(speciesId); + return this.getRestrictedSpeciesIds(generation).includes(normalizedSpeciesId); + } + + computeEffectiveLevel(actualLevel: number, generation: string): number { + const ruleset = this.getRulesetByGeneration(generation); + return computeEffectiveLevelWithPolicy(actualLevel, ruleset.levelPolicy); + } +} + +const defaultRulesetService = new RulesetService(); + +export function getRulesetByGeneration(generation: string): RulesetSummary { + return defaultRulesetService.getRulesetByGeneration(generation); +} + +export function getRestrictedSpeciesIds(generation: string): readonly number[] { + return defaultRulesetService.getRestrictedSpeciesIds(generation); +} + +export function isRestrictedSpecies(generation: string, speciesId: number | string): boolean { + return defaultRulesetService.isRestrictedSpecies(generation, speciesId); +} + +export function computeGenerationEffectiveLevel(actualLevel: number, generation: string): number { + return defaultRulesetService.computeEffectiveLevel(actualLevel, generation); +} diff --git a/src/server/rules/ruleset-types.ts b/src/server/rules/ruleset-types.ts new file mode 100644 index 00000000..6ba49ea0 --- /dev/null +++ b/src/server/rules/ruleset-types.ts @@ -0,0 +1,75 @@ +export const SUPPORTED_PVP_GENERATIONS = [ + 'gen1', + 'gen2', + 'gen3', + 'gen4', + 'gen5', + 'gen6', + 'gen7', + 'gen8', + 'gen9', +] as const; + +export type PvpGeneration = (typeof SUPPORTED_PVP_GENERATIONS)[number]; + +export type RulesetStatus = 'active'; +export type BattleFormat = 'single'; +export type LeadSelectionMode = 'slot1_auto'; +export type ReplacementSelectionMode = 'manual'; +export type LevelDisplayMode = 'actual-level-visible'; +export type EffectiveFormulaKey = 'soft-cap-after-50-v1'; + +export type RulesetKey = `tkm-friendly-${PvpGeneration}-v${number}`; + +export interface PartyPolicy { + size: number; + activePartySlotsPerPlayer: number; + speciesDupClause: boolean; +} + +export interface SpecialLimits { + legendaryMythicalTotal: number; + restrictedTotal: number; +} + +export interface LevelPolicy { + displayMode: LevelDisplayMode; + effectiveFormulaKey: EffectiveFormulaKey; + softCapStartsAt: number; + effectiveLevelCap: number; +} + +export interface BattlePolicy { + format: BattleFormat; + teamPreview: boolean; + leadSelection: LeadSelectionMode; + replacementSelection: ReplacementSelectionMode; + actionTimeoutSeconds: number; +} + +export interface CheatPolicy { + requireCleanSave: boolean; + allowCheatFlaggedSave: boolean; + growthSnapshotRequired: boolean; +} + +export interface RulesetSummary { + generation: PvpGeneration; + rulesetKey: RulesetKey; + status: RulesetStatus; + party: PartyPolicy; + specialLimits: SpecialLimits; + levelPolicy: LevelPolicy; + battlePolicy: BattlePolicy; + cheatPolicy: CheatPolicy; + updatedAt: string; +} + +export interface RulesetRepository { + getActiveRulesetByGeneration(generation: PvpGeneration): RulesetSummary | undefined; + getRestrictedSpeciesIdsByRulesetKey(rulesetKey: RulesetKey): readonly number[] | undefined; +} + +export function isPvpGeneration(value: string): value is PvpGeneration { + return (SUPPORTED_PVP_GENERATIONS as readonly string[]).includes(value); +} diff --git a/src/server/ws/connection-registry.ts b/src/server/ws/connection-registry.ts new file mode 100644 index 00000000..a8539e03 --- /dev/null +++ b/src/server/ws/connection-registry.ts @@ -0,0 +1,139 @@ +import type { BattleServerEventEnvelope } from '../battle/index.js'; +import type { RoomSeat } from '../rooms/index.js'; + +export interface PvpWsPingEnvelope { + type: 'ws.ping'; + sentAt: string; +} + +export interface PvpWsErrorEnvelope { + type: 'ws.error'; + sentAt: string; + code: string; + message: string; + retryable: boolean; + details?: Record; +} + +export type PvpWsOutboundEnvelope = BattleServerEventEnvelope | PvpWsPingEnvelope | PvpWsErrorEnvelope; + +export interface PvpWsTransport { + send(message: PvpWsOutboundEnvelope): void; + close(code: number, reason: string): void; +} + +export interface PvpWsConnectionRecord { + connectionId: string; + roomId: string; + userId: string; + seat: RoomSeat; + battleId: string | null; + transport: PvpWsTransport; + connectedAt: string; + lastSeenAtMs: number; + lastPingAtMs: number | null; +} + +export interface RegisterPvpWsConnectionInput { + connectionId: string; + roomId: string; + userId: string; + seat: RoomSeat; + battleId: string | null; + transport: PvpWsTransport; + now: Date; +} + +function cloneConnection(connection: PvpWsConnectionRecord): PvpWsConnectionRecord { + return { ...connection }; +} + +function createSeatKey(roomId: string, seat: RoomSeat): string { + return `${roomId}:${seat}`; +} + +export class ConnectionRegistry { + private readonly connectionsById = new Map(); + + private readonly connectionIdBySeat = new Map(); + + register(input: RegisterPvpWsConnectionInput): PvpWsConnectionRecord { + const connection: PvpWsConnectionRecord = { + connectionId: input.connectionId, + roomId: input.roomId, + userId: input.userId, + seat: input.seat, + battleId: input.battleId, + transport: input.transport, + connectedAt: input.now.toISOString(), + lastSeenAtMs: input.now.getTime(), + lastPingAtMs: null, + }; + + this.connectionsById.set(connection.connectionId, connection); + this.connectionIdBySeat.set(createSeatKey(connection.roomId, connection.seat), connection.connectionId); + + return cloneConnection(connection); + } + + get(connectionId: string): PvpWsConnectionRecord | undefined { + const connection = this.connectionsById.get(connectionId); + return connection ? cloneConnection(connection) : undefined; + } + + getBySeat(roomId: string, seat: RoomSeat): PvpWsConnectionRecord | undefined { + const connectionId = this.connectionIdBySeat.get(createSeatKey(roomId, seat)); + if (!connectionId) { + return undefined; + } + + return this.get(connectionId); + } + + listByRoom(roomId: string): PvpWsConnectionRecord[] { + return Array.from(this.connectionsById.values()) + .filter((connection) => connection.roomId === roomId) + .map((connection) => cloneConnection(connection)); + } + + listAll(): PvpWsConnectionRecord[] { + return Array.from(this.connectionsById.values(), (connection) => cloneConnection(connection)); + } + + updateBattleIdForRoom(roomId: string, battleId: string): void { + for (const connection of this.connectionsById.values()) { + if (connection.roomId === roomId) { + connection.battleId = battleId; + } + } + } + + markSeen(connectionId: string, now: Date): void { + const connection = this.connectionsById.get(connectionId); + if (!connection) { + return; + } + + connection.lastSeenAtMs = now.getTime(); + } + + markPingSent(connectionId: string, now: Date): void { + const connection = this.connectionsById.get(connectionId); + if (!connection) { + return; + } + + connection.lastPingAtMs = now.getTime(); + } + + remove(connectionId: string): PvpWsConnectionRecord | undefined { + const connection = this.connectionsById.get(connectionId); + if (!connection) { + return undefined; + } + + this.connectionsById.delete(connectionId); + this.connectionIdBySeat.delete(createSeatKey(connection.roomId, connection.seat)); + return cloneConnection(connection); + } +} diff --git a/src/server/ws/heartbeat.ts b/src/server/ws/heartbeat.ts new file mode 100644 index 00000000..a263fe9b --- /dev/null +++ b/src/server/ws/heartbeat.ts @@ -0,0 +1,69 @@ +import { ConnectionRegistry, type PvpWsConnectionRecord } from './connection-registry.js'; + +export const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; +export const DEFAULT_PONG_TIMEOUT_MS = 15_000; + +export interface HeartbeatSweepResult { + pingedConnectionIds: string[]; + timedOutConnectionIds: string[]; +} + +export interface HeartbeatMonitorOptions { + registry: ConnectionRegistry; + now?: () => Date; + pingIntervalMs?: number; + pongTimeoutMs?: number; +} + +export class HeartbeatMonitor { + private readonly registry: ConnectionRegistry; + + private readonly now: () => Date; + + private readonly pingIntervalMs: number; + + private readonly pongTimeoutMs: number; + + constructor(options: HeartbeatMonitorOptions) { + this.registry = options.registry; + this.now = options.now ?? (() => new Date()); + this.pingIntervalMs = options.pingIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + this.pongTimeoutMs = options.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS; + } + + recordPong(connectionId: string): void { + this.registry.markSeen(connectionId, this.now()); + } + + sweep(onTimeout: (connection: PvpWsConnectionRecord) => void): HeartbeatSweepResult { + const now = this.now(); + const nowMs = now.getTime(); + const result: HeartbeatSweepResult = { + pingedConnectionIds: [], + timedOutConnectionIds: [], + }; + + for (const connection of this.registry.listAll()) { + if (nowMs - connection.lastSeenAtMs > this.pongTimeoutMs) { + result.timedOutConnectionIds.push(connection.connectionId); + onTimeout(connection); + continue; + } + + const shouldPing = + connection.lastPingAtMs === null || nowMs - connection.lastPingAtMs >= this.pingIntervalMs; + if (!shouldPing) { + continue; + } + + connection.transport.send({ + type: 'ws.ping', + sentAt: now.toISOString(), + }); + this.registry.markPingSent(connection.connectionId, now); + result.pingedConnectionIds.push(connection.connectionId); + } + + return result; + } +} diff --git a/src/server/ws/index.ts b/src/server/ws/index.ts new file mode 100644 index 00000000..49e6f3c7 --- /dev/null +++ b/src/server/ws/index.ts @@ -0,0 +1,28 @@ +export { + ConnectionRegistry, + type PvpWsConnectionRecord, + type PvpWsErrorEnvelope, + type PvpWsOutboundEnvelope, + type PvpWsPingEnvelope, + type PvpWsTransport, + type RegisterPvpWsConnectionInput, +} from './connection-registry.js'; +export { + DEFAULT_HEARTBEAT_INTERVAL_MS, + DEFAULT_PONG_TIMEOUT_MS, + HeartbeatMonitor, + type HeartbeatMonitorOptions, + type HeartbeatSweepResult, +} from './heartbeat.js'; +export { + MessageRouter, + type PvpWsInboundEnvelope, + type PvpWsPongEnvelope, + type RoutedPvpWsMessage, +} from './message-router.js'; +export { + PvpWsServer, + type PvpWsConnectInput, + type PvpWsConnectionSummary, + type PvpWsServerOptions, +} from './pvp-ws-server.js'; diff --git a/src/server/ws/message-router.ts b/src/server/ws/message-router.ts new file mode 100644 index 00000000..9bac14e1 --- /dev/null +++ b/src/server/ws/message-router.ts @@ -0,0 +1,60 @@ +import type { BattleCommandEnvelope } from '../battle/index.js'; + +export interface PvpWsPongEnvelope { + type: 'ws.pong'; + sentAt: string; +} + +export type PvpWsInboundEnvelope = BattleCommandEnvelope | PvpWsPongEnvelope; + +export type RoutedPvpWsMessage = + | { type: 'battle.command'; envelope: BattleCommandEnvelope } + | { type: 'ws.pong'; envelope: PvpWsPongEnvelope }; + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isBattleCommandEnvelope(value: unknown): value is BattleCommandEnvelope { + if (!isObjectRecord(value)) { + return false; + } + + return ( + value.type === 'battle.command' && + typeof value.roomId === 'string' && + typeof value.battleId === 'string' && + typeof value.seq === 'number' && + typeof value.sentAt === 'string' && + isObjectRecord(value.payload) && + typeof value.payload.clientCommandId === 'string' && + typeof value.payload.turn === 'number' && + typeof value.payload.phase === 'string' && + isObjectRecord(value.payload.command) && + typeof value.payload.command.type === 'string' + ); +} + +function isPongEnvelope(value: unknown): value is PvpWsPongEnvelope { + return isObjectRecord(value) && value.type === 'ws.pong' && typeof value.sentAt === 'string'; +} + +export class MessageRouter { + route(message: unknown): RoutedPvpWsMessage { + if (isBattleCommandEnvelope(message)) { + return { + type: 'battle.command', + envelope: message, + }; + } + + if (isPongEnvelope(message)) { + return { + type: 'ws.pong', + envelope: message, + }; + } + + throw new Error('PVP_WS_MESSAGE_INVALID'); + } +} diff --git a/src/server/ws/pvp-ws-server.ts b/src/server/ws/pvp-ws-server.ts new file mode 100644 index 00000000..a39147ca --- /dev/null +++ b/src/server/ws/pvp-ws-server.ts @@ -0,0 +1,445 @@ +import { + BattleSessionService, + createTimeoutCommandEnvelope, + getTimedOutSeats, + isBattleRequestTimedOut, + type BattleCommandEnvelope, + type BattleDebugView, + type BattleSessionRecord, +} from '../battle/index.js'; +import { buildBattleDebugView, buildRoomSnapshotPayload } from '../projection/battle-projection.js'; +import type { ActivePartySnapshot } from '../parties/index.js'; +import type { BattleRoomRecord, RoomPresence, RoomRepository, RoomSeat } from '../rooms/index.js'; +import { + ConnectionRegistry, + type PvpWsConnectionRecord, + type PvpWsOutboundEnvelope, + type PvpWsTransport, +} from './connection-registry.js'; +import { HeartbeatMonitor, type HeartbeatSweepResult } from './heartbeat.js'; +import { MessageRouter, type PvpWsInboundEnvelope } from './message-router.js'; + +export interface PvpWsServerOptions { + authenticate(token: string): { userId: string } | null; + roomRepository: RoomRepository; + battleSessionService: BattleSessionService; + loadPartySnapshot(snapshotId: string): ActivePartySnapshot | undefined; + now?: () => Date; +} + +export interface PvpWsConnectInput { + roomId: string; + token: string; + connectionId: string; + transport: PvpWsTransport; + resume?: boolean; +} + +export interface PvpWsConnectionSummary { + connectionId: string; + seat: RoomSeat; + battleId: string | null; +} + +export interface BattleTimeoutSweepResult { + processedBattles: number; +} + +function cloneRoom(room: BattleRoomRecord): BattleRoomRecord { + return structuredClone(room); +} + +function cloneSession(session: BattleSessionRecord): BattleSessionRecord { + return structuredClone(session); +} + +function createError(code: string, message = code): Error { + return new Error(message); +} + +function resolveSeat(room: BattleRoomRecord, userId: string): RoomSeat { + if (room.host.userId === userId) { + return 'host'; + } + + if (room.guest?.userId === userId) { + return 'guest'; + } + + throw createError('PVP_ROOM_ACCESS_DENIED'); +} + +function getSeatBinding(room: BattleRoomRecord, seat: RoomSeat) { + return seat === 'host' ? room.host : room.guest; +} + +export class PvpWsServer { + private readonly authenticate: PvpWsServerOptions['authenticate']; + + private readonly roomRepository: RoomRepository; + + private readonly battleSessionService: BattleSessionService; + + private readonly loadPartySnapshot: PvpWsServerOptions['loadPartySnapshot']; + + private readonly now: () => Date; + + private readonly registry = new ConnectionRegistry(); + + private readonly heartbeatMonitor: HeartbeatMonitor; + + private readonly messageRouter = new MessageRouter(); + + private readonly sessionsByRoomId = new Map(); + + constructor(options: PvpWsServerOptions) { + this.authenticate = options.authenticate; + this.roomRepository = options.roomRepository; + this.battleSessionService = options.battleSessionService; + this.loadPartySnapshot = options.loadPartySnapshot; + this.now = options.now ?? (() => new Date()); + this.heartbeatMonitor = new HeartbeatMonitor({ + registry: this.registry, + now: this.now, + }); + } + + connectClient(input: PvpWsConnectInput): PvpWsConnectionSummary { + const auth = this.authenticate(input.token); + if (!auth) { + input.transport.close(4003, 'PVP_WS_AUTH_INVALID'); + throw createError('PVP_WS_AUTH_INVALID'); + } + + const room = this.getRoomOrThrow(input.roomId); + const seat = resolveSeat(room, auth.userId); + const hadExistingSession = this.sessionsByRoomId.has(input.roomId); + const duplicate = this.registry.getBySeat(input.roomId, seat); + if (duplicate) { + if (!input.resume) { + input.transport.close(4001, 'PVP_WS_DUPLICATE_CONNECTION'); + throw createError('PVP_WS_DUPLICATE_CONNECTION'); + } + + this.registry.remove(duplicate.connectionId); + duplicate.transport.close(4001, 'PVP_WS_CONNECTION_REPLACED'); + } + + const existingSession = this.sessionsByRoomId.get(input.roomId); + const connection = this.registry.register({ + connectionId: input.connectionId, + roomId: input.roomId, + userId: auth.userId, + seat, + battleId: existingSession?.battleId ?? null, + transport: input.transport, + now: this.now(), + }); + + const roomWithPresence = this.persistPresence(input.roomId, seat, 'connected'); + const startedSession = this.ensureSessionStarted(roomWithPresence); + const activeSession = this.sessionsByRoomId.get(input.roomId) ?? startedSession ?? existingSession; + + if (hadExistingSession && activeSession) { + const resumedSession = cloneSession(activeSession); + this.sendCurrentSnapshot(resumedSession, seat, input.transport); + } + + return { + connectionId: connection.connectionId, + seat, + battleId: activeSession?.battleId ?? null, + }; + } + + receiveMessage(connectionId: string, message: PvpWsInboundEnvelope): void { + const connection = this.getConnectionOrThrow(connectionId); + this.registry.markSeen(connectionId, this.now()); + + const routed = this.messageRouter.route(message); + if (routed.type === 'ws.pong') { + this.heartbeatMonitor.recordPong(connectionId); + return; + } + + const session = this.getSessionForCommand(connection, routed.envelope); + const result = this.battleSessionService.submitCommand({ + session, + seat: connection.seat, + envelope: routed.envelope, + }); + + this.persistMutationResult(result); + } + + disconnectClient( + connectionId: string, + closeInfo: { code: number; reason: string } = { + code: 4000, + reason: 'PVP_WS_DISCONNECTED', + }, + ): void { + const connection = this.registry.remove(connectionId); + if (!connection) { + return; + } + + connection.transport.close(closeInfo.code, closeInfo.reason); + this.persistPresence(connection.roomId, connection.seat, 'disconnected'); + } + + sweepHeartbeats(): HeartbeatSweepResult { + return this.heartbeatMonitor.sweep((connection) => { + this.disconnectClient(connection.connectionId, { + code: 4002, + reason: 'PVP_WS_HEARTBEAT_TIMEOUT', + }); + }); + } + + sweepBattleTimeouts(): BattleTimeoutSweepResult { + const now = this.now(); + let processedBattles = 0; + + for (const [roomId, storedSession] of this.sessionsByRoomId.entries()) { + if (!storedSession.requestState || !isBattleRequestTimedOut(storedSession, now)) { + continue; + } + + let session = cloneSession(storedSession); + const timedOutSeats = getTimedOutSeats(session, now); + if (timedOutSeats.length === 0) { + continue; + } + + processedBattles += 1; + for (const seat of timedOutSeats) { + session.timeoutState[seat].consecutive += 1; + session.timeoutState[seat].total += 1; + session.timeoutState[seat].lastTimeoutAt = now.toISOString(); + } + + const forfeitingSeat = timedOutSeats.find((seat) => session.timeoutState[seat].consecutive >= 2); + if (forfeitingSeat) { + const result = this.battleSessionService.submitTimeoutForfeit({ + session, + loserSeat: forfeitingSeat, + }); + this.persistMutationResult(result); + continue; + } + + for (const seat of timedOutSeats) { + const envelope = createTimeoutCommandEnvelope({ session, seat, now }); + const result = this.battleSessionService.submitCommand({ + session, + seat, + envelope, + source: 'timeout_auto', + }); + session = result.session; + this.persistMutationResult(result); + } + + const current = this.sessionsByRoomId.get(roomId); + if (current && (current.phase === 'finished' || current.phase === 'abandoned')) { + this.syncFinishedRoom(current); + } + } + + return { processedBattles }; + } + + getBattleSession(roomId: string): BattleSessionRecord | undefined { + const session = this.sessionsByRoomId.get(roomId); + return session ? cloneSession(session) : undefined; + } + + getBattleDebugView(roomId: string): BattleDebugView | undefined { + const session = this.sessionsByRoomId.get(roomId); + return session ? buildBattleDebugView(session) : undefined; + } + + private ensureSessionStarted(room: BattleRoomRecord): BattleSessionRecord | undefined { + const existingSession = this.sessionsByRoomId.get(room.room.roomId); + if (existingSession) { + this.registry.updateBattleIdForRoom(room.room.roomId, existingSession.battleId); + return cloneSession(existingSession); + } + + if (!room.guest) { + return undefined; + } + + if (room.host.presence !== 'connected' || room.guest.presence !== 'connected') { + return undefined; + } + + const hostParty = this.loadPartySnapshotOrThrow(room.host.partySnapshotId); + const guestParty = this.loadPartySnapshotOrThrow(room.guest.partySnapshotId); + const startedAt = this.now().toISOString(); + const startedRoom: BattleRoomRecord = { + ...cloneRoom(room), + room: { + ...room.room, + status: 'in_progress', + startedAt, + expiresAt: null, + }, + }; + + this.roomRepository.saveRoom(startedRoom); + + const result = this.battleSessionService.createSession({ + room: startedRoom, + hostParty, + guestParty, + }); + this.persistMutationResult(result); + + const session = this.sessionsByRoomId.get(result.session.roomId); + return session ? cloneSession(session) : cloneSession(result.session); + } + + private syncFinishedRoom(session: BattleSessionRecord): void { + if (session.phase !== 'finished' && session.phase !== 'abandoned') { + return; + } + + const room = this.getRoomOrThrow(session.roomId); + const nextRoom: BattleRoomRecord = { + ...room, + room: { + ...room.room, + status: 'finished', + finishedAt: session.updatedAt, + }, + }; + this.roomRepository.saveRoom(nextRoom); + } + + private getRoomOrThrow(roomId: string): BattleRoomRecord { + const room = this.roomRepository.getRoom(roomId); + if (!room) { + throw createError('PVP_ROOM_NOT_FOUND'); + } + + return room; + } + + private getConnectionOrThrow(connectionId: string): PvpWsConnectionRecord { + const connection = this.registry.get(connectionId); + if (!connection) { + throw createError('PVP_WS_CONNECTION_NOT_FOUND'); + } + + return connection; + } + + private getSessionForCommand( + connection: PvpWsConnectionRecord, + envelope: BattleCommandEnvelope, + ): BattleSessionRecord { + if (envelope.roomId !== connection.roomId) { + throw createError('PVP_COMMAND_BATTLE_MISMATCH'); + } + + const session = this.sessionsByRoomId.get(connection.roomId); + if (!session) { + throw createError('PVP_BATTLE_NOT_READY'); + } + + if (envelope.battleId !== session.battleId) { + throw createError('PVP_COMMAND_BATTLE_MISMATCH'); + } + + return cloneSession(session); + } + + private loadPartySnapshotOrThrow(snapshotId: string): ActivePartySnapshot { + const snapshot = this.loadPartySnapshot(snapshotId); + if (!snapshot) { + throw createError('PVP_PARTY_SNAPSHOT_NOT_FOUND'); + } + + return structuredClone(snapshot); + } + + private persistPresence(roomId: string, seat: RoomSeat, presence: RoomPresence): BattleRoomRecord { + const room = this.getRoomOrThrow(roomId); + const binding = getSeatBinding(room, seat); + if (!binding) { + throw createError('PVP_ROOM_ACCESS_DENIED'); + } + + const nextRoom: BattleRoomRecord = + seat === 'host' + ? { + ...room, + host: { + ...room.host, + presence, + }, + } + : { + ...room, + guest: room.guest + ? { + ...room.guest, + presence, + } + : null, + }; + + this.roomRepository.saveRoom(nextRoom); + return nextRoom; + } + + private persistMutationResult(result: { session: BattleSessionRecord; eventsBySeat: Record }): void { + this.sessionsByRoomId.set(result.session.roomId, cloneSession(result.session)); + this.dispatchEvents(result.session.roomId, 'host', result.eventsBySeat.host); + this.dispatchEvents(result.session.roomId, 'guest', result.eventsBySeat.guest); + this.syncFinishedRoom(result.session); + } + + private sendCurrentSnapshot( + session: BattleSessionRecord, + seat: RoomSeat, + transport: PvpWsTransport, + ): void { + const sentAt = this.now().toISOString(); + const snapshot: PvpWsOutboundEnvelope = { + type: 'room.snapshot', + roomId: session.roomId, + battleId: session.battleId, + seq: session.nextSeq, + sentAt, + payload: buildRoomSnapshotPayload(session, seat, new Date(sentAt)), + }; + session.nextSeq += 1; + session.updatedAt = sentAt; + session.eventLog.push({ + seat, + type: 'room.snapshot', + seq: snapshot.seq, + sentAt, + }); + this.sessionsByRoomId.set(session.roomId, cloneSession(session)); + transport.send(snapshot); + } + + private dispatchEvents( + roomId: string, + seat: RoomSeat, + messages: readonly PvpWsOutboundEnvelope[], + ): void { + const connection = this.registry.getBySeat(roomId, seat); + if (!connection) { + return; + } + + for (const message of messages) { + connection.transport.send(message); + } + } +} diff --git a/test/pvp-action-request-view.test.ts b/test/pvp-action-request-view.test.ts new file mode 100644 index 00000000..0457e0de --- /dev/null +++ b/test/pvp-action-request-view.test.ts @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createPvpActionRequestView, + createPvpActionRequestViewFromPendingRequest, + createPvpClientState, + createPvpSessionState, + type PendingCommandState, + type PvpPendingRequest, + type PvpSessionClientState, +} from '../src/pvp/index.js'; + +function createBaseSessionClientState(): PvpSessionClientState { + const session = createPvpSessionState(); + const protocol = createPvpClientState(); + protocol.session = session; + + return { + transportStatus: 'connected', + session, + protocol, + reconnect: { + autoReconnectEnabled: true, + attempt: 0, + scheduled: false, + delay: null, + nextReconnectAt: null, + lastTrigger: 'manual_connect', + }, + canSendCommand: false, + hasPendingRequest: false, + activeRequestKind: null, + }; +} + +function buildActionRequest(overrides: Partial> = {}): Extract { + return { + kind: 'choose_move_or_switch', + phase: 'awaiting_actions', + turn: 12, + deadlineMs: 30_000, + commandSubmitted: false, + requestId: 'req-action-12', + activePokemon: { + slot: 1, + speciesId: '001', + nickname: 'Bulba', + levelActual: 55, + levelEffective: 52, + hp: 120, + hpMax: 150, + status: 'poison', + fainted: false, + }, + availableMoves: [ + { slot: 2, id: 'growl', disabled: true, currentPp: 40 }, + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + ], + availableSwitches: [ + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: true }, + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + ], + ...overrides, + }; +} + +function buildReplacementRequest(overrides: Partial> = {}): Extract { + return { + kind: 'choose_replacement', + phase: 'awaiting_replacement', + turn: 13, + deadlineMs: 20_000, + commandSubmitted: false, + requestId: 'req-replace-13', + faintedSlot: 1, + availableReplacements: [ + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: true }, + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + ], + ...overrides, + }; +} + +function setPendingCommand(state: PvpSessionClientState, overrides: Partial = {}): void { + state.session.pendingCommand = { + clientCommandId: 'cmd-1', + turn: state.session.pendingRequest?.turn ?? 12, + phase: state.session.pendingRequest?.phase ?? 'awaiting_actions', + command: { type: 'choose_move', moveSlot: 1 }, + seq: 1, + sentAt: '2026-04-11T14:00:00.000Z', + status: 'created', + lockedIn: false, + ...overrides, + }; +} + +describe('pvp action request view', () => { + it('renders choose_move_or_switch requests into menu sections and command previews', () => { + const state = createBaseSessionClientState(); + state.session.pendingRequest = buildActionRequest(); + state.session.battleStatus = 'awaiting_actions'; + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + + const view = createPvpActionRequestView(state); + + assert.ok(view); + assert.equal(view.requestKind, 'choose_move_or_switch'); + assert.equal(view.title, 'Bulba (001) 행동 선택'); + assert.equal(view.statusLabel, '선택 가능'); + assert.equal(view.locked, false); + assert.equal(view.canInteract, true); + assert.equal(view.activePokemonLabel, 'Bulba (001) · Lv.52 (actual 55) · HP 120/150 · status: poison'); + assert.deepEqual(view.sections.map((section) => section.id), ['moves', 'switches', 'system']); + + const moves = view.sections[0]?.entries; + assert.equal(moves?.length, 2); + assert.equal(moves?.[0]?.label, '1. tackle'); + assert.equal(moves?.[0]?.enabled, true); + assert.equal(moves?.[0]?.inputToken, '1'); + assert.deepEqual(moves?.[0]?.command, { type: 'choose_move', moveSlot: 1 }); + assert.equal(moves?.[1]?.label, '2. growl'); + assert.equal(moves?.[1]?.enabled, false); + assert.equal(moves?.[1]?.disabledReason, '사용할 수 없는 기술'); + + const switches = view.sections[1]?.entries; + assert.equal(switches?.length, 2); + assert.equal(switches?.[0]?.label, 'Charmy (004)'); + assert.equal(switches?.[0]?.inputToken, 'switch:2'); + assert.deepEqual(switches?.[0]?.command, { type: 'choose_switch', targetSlot: 2 }); + assert.equal(switches?.[1]?.enabled, false); + assert.equal(switches?.[1]?.disabledReason, '기절한 포켓몬은 교체할 수 없음'); + + const system = view.sections[2]?.entries; + assert.equal(system?.length, 1); + assert.equal(system?.[0]?.label, '항복'); + assert.deepEqual(system?.[0]?.command, { type: 'forfeit' }); + }); + + it('marks submitted requests as locked and disables every menu entry', () => { + const state = createBaseSessionClientState(); + state.session.pendingRequest = buildActionRequest({ commandSubmitted: true }); + state.session.battleStatus = 'awaiting_actions'; + state.canSendCommand = false; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + + const view = createPvpActionRequestView(state); + + assert.ok(view); + assert.equal(view.commandSubmitted, true); + assert.equal(view.locked, true); + assert.equal(view.canInteract, false); + assert.equal(view.statusLabel, '이미 제출됨'); + assert.equal(view.sections.every((section) => section.entries.every((entry) => entry.enabled === false)), true); + }); + + it('marks pending-command state as waiting for server confirmation', () => { + const state = createBaseSessionClientState(); + state.session.pendingRequest = buildActionRequest(); + state.session.battleStatus = 'awaiting_actions'; + state.canSendCommand = false; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + setPendingCommand(state); + + const view = createPvpActionRequestView(state); + + assert.ok(view); + assert.equal(view.locked, true); + assert.equal(view.commandSubmitted, false); + assert.equal(view.statusLabel, '서버 확인 대기 중'); + assert.equal(view.sections[0]?.entries[0]?.enabled, false); + }); + + it('renders choose_replacement requests with replacement-specific labels', () => { + const state = createBaseSessionClientState(); + state.session.pendingRequest = buildReplacementRequest(); + state.session.battleStatus = 'awaiting_replacement'; + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_replacement'; + + const view = createPvpActionRequestView(state); + + assert.ok(view); + assert.equal(view.requestKind, 'choose_replacement'); + assert.equal(view.title, '교체 포켓몬 선택'); + assert.equal(view.prompt, '1번 슬롯이 기절했습니다. 교체 포켓몬을 선택하세요.'); + assert.equal(view.activePokemonLabel, null); + assert.deepEqual(view.sections.map((section) => section.id), ['replacements', 'system']); + + const replacements = view.sections[0]?.entries; + assert.equal(replacements?.length, 2); + assert.equal(replacements?.[0]?.label, 'Charmy (004)'); + assert.equal(replacements?.[0]?.inputToken, 'replace:2'); + assert.deepEqual(replacements?.[0]?.command, { type: 'choose_replacement', targetSlot: 2 }); + assert.equal(replacements?.[1]?.enabled, false); + assert.equal(replacements?.[1]?.disabledReason, '기절한 포켓몬은 교체할 수 없음'); + }); + + it('can build a view directly from a pending request without full session client state', () => { + const view = createPvpActionRequestViewFromPendingRequest(buildReplacementRequest(), { + canInteract: false, + }); + + assert.ok(view); + assert.equal(view.statusLabel, '현재 선택 불가'); + assert.equal(view.canInteract, false); + assert.equal(view.sections[0]?.entries[0]?.enabled, false); + }); + + it('returns null when there is no pending request', () => { + const state = createBaseSessionClientState(); + + assert.equal(createPvpActionRequestView(state), null); + }); +}); diff --git a/test/pvp-battle-session.test.ts b/test/pvp-battle-session.test.ts new file mode 100644 index 00000000..5563d9e0 --- /dev/null +++ b/test/pvp-battle-session.test.ts @@ -0,0 +1,550 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; + +import { initLocale } from '../src/i18n/index.js'; +import type { MoveData, PokemonData } from '../src/core/types.js'; +import type { ActivePartySnapshot } from '../src/server/parties/index.js'; +import type { BattleRoomRecord } from '../src/server/rooms/index.js'; +import type { RulesetSummary } from '../src/server/rules/index.js'; +import { + BattleSessionService, + type BattleCommandEnvelope, + type BattleDataResolver, + type BattleServerEventEnvelope, + type BattleSessionRecord, +} from '../src/server/battle/index.js'; + +initLocale('ko'); + +const RULESET: RulesetSummary = { + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v1', + status: 'active', + party: { + size: 6, + activePartySlotsPerPlayer: 1, + speciesDupClause: true, + }, + specialLimits: { + legendaryMythicalTotal: 2, + restrictedTotal: 1, + }, + levelPolicy: { + displayMode: 'actual-level-visible', + effectiveFormulaKey: 'soft-cap-after-50-v1', + softCapStartsAt: 50, + effectiveLevelCap: 60, + }, + battlePolicy: { + format: 'single', + teamPreview: false, + leadSelection: 'slot1_auto', + replacementSelection: 'manual', + actionTimeoutSeconds: 45, + }, + cheatPolicy: { + requireCleanSave: true, + allowCheatFlaggedSave: false, + growthSnapshotRequired: true, + }, + updatedAt: '2026-04-11T07:00:00.000Z', +}; + +const SPECIES_DATA: Record = { + '001': { + id: 1, + name: 'Bulbasaur', + types: ['grass'], + stage: 1, + line: ['Bulbasaur'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 80, attack: 85, defense: 80, speed: 90, sp_attack: 95, sp_defense: 85 }, + catch_rate: 45, + }, + '004': { + id: 4, + name: 'Charmander', + types: ['fire'], + stage: 1, + line: ['Charmander'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 78, attack: 84, defense: 72, speed: 88, sp_attack: 100, sp_defense: 78 }, + catch_rate: 45, + }, + '007': { + id: 7, + name: 'Squirtle', + types: ['water'], + stage: 1, + line: ['Squirtle'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 79, attack: 83, defense: 100, speed: 60, sp_attack: 85, sp_defense: 105 }, + catch_rate: 45, + }, + '025': { + id: 25, + name: 'Pikachu', + types: ['electric'], + stage: 1, + line: ['Pikachu'], + evolves_at: null, + unlock: 'starter', + exp_group: 'medium_fast', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 70, attack: 60, defense: 55, speed: 110, sp_attack: 70, sp_defense: 60 }, + catch_rate: 190, + }, +}; + +const MOVE_DATA: Record = { + 'host-fast': { + id: 101, + name: 'host-fast', + nameKo: '호스트 속공', + nameEn: 'Host Fast', + type: 'normal', + category: 'physical', + power: 55, + accuracy: 100, + pp: 20, + }, + 'host-ko': { + id: 102, + name: 'host-ko', + nameKo: '호스트 일격', + nameEn: 'Host KO', + type: 'normal', + category: 'physical', + power: 300, + accuracy: 100, + pp: 5, + }, + 'host-chip': { + id: 103, + name: 'host-chip', + nameKo: '호스트 견제', + nameEn: 'Host Chip', + type: 'grass', + category: 'special', + power: 35, + accuracy: 100, + pp: 25, + }, + 'host-guard': { + id: 104, + name: 'host-guard', + nameKo: '호스트 가드', + nameEn: 'Host Guard', + type: 'normal', + category: 'status', + power: 0, + accuracy: null, + pp: 20, + }, + 'guest-fast': { + id: 201, + name: 'guest-fast', + nameKo: '게스트 속공', + nameEn: 'Guest Fast', + type: 'normal', + category: 'physical', + power: 50, + accuracy: 100, + pp: 20, + }, + 'guest-chip': { + id: 202, + name: 'guest-chip', + nameKo: '게스트 견제', + nameEn: 'Guest Chip', + type: 'fire', + category: 'special', + power: 30, + accuracy: 100, + pp: 25, + }, + 'guest-guard': { + id: 203, + name: 'guest-guard', + nameKo: '게스트 가드', + nameEn: 'Guest Guard', + type: 'normal', + category: 'status', + power: 0, + accuracy: null, + pp: 20, + }, + 'guest-finisher': { + id: 204, + name: 'guest-finisher', + nameKo: '게스트 마무리', + nameEn: 'Guest Finisher', + type: 'water', + category: 'special', + power: 85, + accuracy: 100, + pp: 10, + }, +}; + +const RESOLVER: BattleDataResolver = { + resolveSpecies(_generation, speciesId) { + return SPECIES_DATA[speciesId]; + }, + resolveMove(_generation, moveId) { + return MOVE_DATA[moveId]; + }, +}; + +function makeParty(playerId: string, members: Array<{ slot: number; speciesId: string; levelActual: number; levelEffective?: number; moves: string[] }>): ActivePartySnapshot { + return { + snapshotId: `party_${playerId}`, + snapshotVersion: 1, + playerId, + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v1', + status: 'active', + isActive: true, + registeredAt: '2026-04-11T07:00:00.000Z', + sourceStateHash: `sha256:${playerId}:state`, + sourceConfigHash: `sha256:${playerId}:config`, + clientBuild: 'tokenmon-cli/0.120.0', + validationStatus: 'accepted', + proofVersion: 'v1', + capturedAt: '2026-04-11T07:00:00.000Z', + sourceSaveId: `save_${playerId}`, + sourceSaveRevision: 1, + partySummary: { + memberCount: members.length, + legendaryMythicalCount: 0, + restrictedCount: 0, + speciesDupClause: true, + }, + members: members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: `${playerId}-pkm-${member.slot}`, + speciesId: member.speciesId, + nickname: `${playerId}-${member.slot}`, + levelActual: member.levelActual, + levelEffective: member.levelEffective ?? Math.min(member.levelActual, 60), + specialClass: { + legendary: false, + mythical: false, + restricted: false, + }, + moves: member.moves, + })), + growthProof: { + proofVersion: 'v1', + capturedAt: '2026-04-11T07:00:00.000Z', + sourceSaveId: `save_${playerId}`, + sourceSaveRevision: 1, + cheatFlags: { + hasCheatHistory: false, + flags: [], + }, + memberProofs: members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: `${playerId}-pkm-${member.slot}`, + speciesId: member.speciesId, + levelActual: member.levelActual, + movesHash: `sha256:${playerId}:moves:${member.slot}`, + stateHash: `sha256:${playerId}:state:${member.slot}`, + })), + }, + }; +} + +function makeRoom(): BattleRoomRecord { + return { + room: { + roomId: 'room_test_01', + roomCode: 'A7KQ2M', + mode: 'friendly_private', + visibility: 'private_friend', + status: 'awaiting_presence', + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v1', + createdByUserId: 'host-user', + createdAt: '2026-04-11T07:00:00.000Z', + expiresAt: null, + startedAt: '2026-04-11T07:01:00.000Z', + finishedAt: null, + cancelledAt: null, + }, + host: { + seat: 'host', + userId: 'host-user', + partySnapshotId: 'party_host-user', + partySnapshotVersion: 1, + partyValidationStatus: 'accepted', + presence: 'connected', + joinedAt: '2026-04-11T07:00:30.000Z', + battleReady: true, + }, + guest: { + seat: 'guest', + userId: 'guest-user', + partySnapshotId: 'party_guest-user', + partySnapshotVersion: 1, + partyValidationStatus: 'accepted', + presence: 'connected', + joinedAt: '2026-04-11T07:00:40.000Z', + battleReady: true, + }, + rulesetSnapshot: RULESET, + battleFreeze: { + freezeStatus: 'pending_presence', + preparedAt: '2026-04-11T07:00:50.000Z', + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v1', + rulesetHash: 'sha256:test', + rulesetSnapshot: RULESET, + hostPartySnapshotId: 'party_host-user', + hostPartySnapshotVersion: 1, + guestPartySnapshotId: 'party_guest-user', + guestPartySnapshotVersion: 1, + battleSeed: 'battle-seed-1', + }, + }; +} + +function createService() { + let tick = 0; + return new BattleSessionService({ + dataResolver: RESOLVER, + battleIdGenerator: () => 'battle_test_01', + now: () => new Date(Date.UTC(2026, 3, 11, 7, 1, tick++)), + }); +} + +function findEvent( + events: BattleServerEventEnvelope[], + type: TType, +): Extract { + const event = events.find((entry) => entry.type === type); + assert.ok(event, `expected ${type} event`); + return event as Extract; +} + +function command( + turn: number, + phase: BattleSessionRecord['phase'], + clientCommandId: string, + inner: BattleCommandEnvelope['payload']['command'], +): BattleCommandEnvelope { + return { + type: 'battle.command', + roomId: 'room_test_01', + battleId: 'battle_test_01', + seq: 1, + sentAt: '2026-04-11T07:02:00.000Z', + payload: { + clientCommandId, + turn, + phase, + command: inner, + }, + }; +} + +describe('BattleSessionService', () => { + let originalRandom: typeof Math.random; + + beforeEach(() => { + originalRandom = Math.random; + Math.random = () => 0; + }); + + it('배틀 시작 snapshot에서 viewer별 공개 정보와 액션 요청을 분리한다', () => { + const service = createService(); + const hostParty = makeParty('host-user', [ + { slot: 1, speciesId: '001', levelActual: 63, moves: ['host-fast', 'host-ko', 'host-chip', 'host-guard'] }, + { slot: 2, speciesId: '025', levelActual: 55, moves: ['host-fast', 'host-chip', 'host-guard', 'host-fast'] }, + ]); + const guestParty = makeParty('guest-user', [ + { slot: 1, speciesId: '004', levelActual: 61, moves: ['guest-fast', 'guest-chip', 'guest-guard', 'guest-finisher'] }, + { slot: 2, speciesId: '007', levelActual: 54, moves: ['guest-fast', 'guest-chip', 'guest-guard', 'guest-finisher'] }, + ]); + + const created = service.createSession({ room: makeRoom(), hostParty, guestParty }); + + assert.equal(created.session.phase, 'awaiting_actions'); + assert.equal(created.session.turn, 1); + + const hostSnapshot = findEvent(created.eventsBySeat.host, 'room.snapshot'); + const guestSnapshot = findEvent(created.eventsBySeat.guest, 'room.snapshot'); + + assert.equal(hostSnapshot.payload.visibleState.self.active.slot, 1); + assert.equal(hostSnapshot.payload.visibleState.self.active.moves.length, 4); + assert.equal(hostSnapshot.payload.visibleState.opponent.active.speciesId, '004'); + assert.equal(hostSnapshot.payload.visibleState.opponent.benchCount, 1); + assert.equal('bench' in hostSnapshot.payload.visibleState.opponent, false); + assert.equal('moves' in hostSnapshot.payload.visibleState.opponent.active, false); + + assert.equal(guestSnapshot.payload.visibleState.self.active.speciesId, '004'); + assert.equal(guestSnapshot.payload.visibleState.opponent.active.speciesId, '001'); + assert.equal(guestSnapshot.payload.pendingRequest?.kind, 'choose_move_or_switch'); + + const hostRequest = findEvent(created.eventsBySeat.host, 'battle.request_action'); + assert.equal(hostRequest.payload.phase, 'awaiting_actions'); + assert.equal(hostRequest.payload.request.availableSwitches.length, 1); + + Math.random = originalRandom; + }); + + it('양측 일반 턴 명령을 수집하고 resolve한 뒤 다음 액션 요청을 만든다', () => { + const service = createService(); + const created = service.createSession({ + room: makeRoom(), + hostParty: makeParty('host-user', [ + { slot: 1, speciesId: '001', levelActual: 60, moves: ['host-fast', 'host-chip', 'host-guard', 'host-ko'] }, + { slot: 2, speciesId: '025', levelActual: 58, moves: ['host-fast', 'host-chip', 'host-guard', 'host-fast'] }, + ]), + guestParty: makeParty('guest-user', [ + { slot: 1, speciesId: '004', levelActual: 60, moves: ['guest-fast', 'guest-chip', 'guest-guard', 'guest-finisher'] }, + { slot: 2, speciesId: '007', levelActual: 58, moves: ['guest-fast', 'guest-chip', 'guest-guard', 'guest-finisher'] }, + ]), + }); + + let session = created.session; + + const first = service.submitCommand({ + session, + seat: 'host', + envelope: command(1, 'awaiting_actions', 'cmd-host-switch', { type: 'choose_switch', targetSlot: 2 }), + }); + session = first.session; + const accepted = findEvent(first.eventsBySeat.host, 'battle.command_accepted'); + assert.equal(accepted.payload.lockedIn, true); + assert.equal(first.eventsBySeat.guest.some((event) => event.type === 'battle.turn_resolved'), false); + + const second = service.submitCommand({ + session, + seat: 'guest', + envelope: command(1, 'awaiting_actions', 'cmd-guest-move', { type: 'choose_move', moveSlot: 1 }), + }); + session = second.session; + + const hostResolved = findEvent(second.eventsBySeat.host, 'battle.turn_resolved'); + assert.equal(hostResolved.payload.turn, 1); + assert.equal(hostResolved.payload.nextPhase, 'awaiting_actions'); + assert.equal(hostResolved.payload.postTurnVisibleState.self.active.slot, 2); + assert.ok(hostResolved.payload.events.some((event) => event.eventType === 'switch_used')); + + const nextRequest = findEvent(second.eventsBySeat.host, 'battle.request_action'); + assert.equal(nextRequest.payload.turn, 2); + assert.equal(session.turn, 2); + assert.equal(session.phase, 'awaiting_actions'); + + Math.random = originalRandom; + }); + + it('기절 시 replacement phase로 전환하고 대상 플레이어만 교체를 고르게 한다', () => { + const service = createService(); + const created = service.createSession({ + room: makeRoom(), + hostParty: makeParty('host-user', [ + { slot: 1, speciesId: '001', levelActual: 60, moves: ['host-ko', 'host-fast', 'host-chip', 'host-guard'] }, + { slot: 2, speciesId: '025', levelActual: 59, moves: ['host-fast', 'host-chip', 'host-guard', 'host-fast'] }, + ]), + guestParty: makeParty('guest-user', [ + { slot: 1, speciesId: '004', levelActual: 50, moves: ['guest-fast', 'guest-chip', 'guest-guard', 'guest-finisher'] }, + { slot: 2, speciesId: '007', levelActual: 58, moves: ['guest-fast', 'guest-chip', 'guest-guard', 'guest-finisher'] }, + ]), + }); + + let session = service.submitCommand({ + session: created.session, + seat: 'host', + envelope: command(1, 'awaiting_actions', 'cmd-host-ko', { type: 'choose_move', moveSlot: 1 }), + }).session; + + const resolved = service.submitCommand({ + session, + seat: 'guest', + envelope: command(1, 'awaiting_actions', 'cmd-guest-fast', { type: 'choose_move', moveSlot: 1 }), + }); + session = resolved.session; + + const hostTurnResolved = findEvent(resolved.eventsBySeat.host, 'battle.turn_resolved'); + assert.equal(hostTurnResolved.payload.nextPhase, 'awaiting_replacement'); + const guestReplacement = findEvent(resolved.eventsBySeat.guest, 'battle.force_replacement'); + assert.equal(guestReplacement.payload.availableReplacements.length, 1); + assert.equal(resolved.eventsBySeat.host.some((event) => event.type === 'battle.force_replacement'), false); + assert.equal(session.phase, 'awaiting_replacement'); + + const rejected = service.submitCommand({ + session, + seat: 'host', + envelope: command(1, 'awaiting_replacement', 'cmd-host-illegal', { type: 'choose_move', moveSlot: 1 }), + }); + const hostRejected = findEvent(rejected.eventsBySeat.host, 'battle.command_rejected'); + assert.equal(hostRejected.payload.code, 'PVP_COMMAND_PHASE_MISMATCH'); + + const replaced = service.submitCommand({ + session, + seat: 'guest', + envelope: command(1, 'awaiting_replacement', 'cmd-guest-replace', { type: 'choose_replacement', targetSlot: 2 }), + }); + session = replaced.session; + + const replacementResolved = findEvent(replaced.eventsBySeat.host, 'battle.turn_resolved'); + assert.equal(replacementResolved.payload.nextPhase, 'awaiting_actions'); + assert.equal(replacementResolved.payload.postTurnVisibleState.opponent.active.speciesId, '007'); + const nextRequest = findEvent(replaced.eventsBySeat.guest, 'battle.request_action'); + assert.equal(nextRequest.payload.turn, 2); + assert.equal(session.turn, 2); + assert.equal(session.phase, 'awaiting_actions'); + + Math.random = originalRandom; + }); + + it('forfeit 명령은 즉시 종료와 결과 기록을 만든다', () => { + const service = createService(); + const created = service.createSession({ + room: makeRoom(), + hostParty: makeParty('host-user', [ + { slot: 1, speciesId: '001', levelActual: 60, moves: ['host-fast', 'host-chip', 'host-guard', 'host-ko'] }, + { slot: 2, speciesId: '025', levelActual: 58, moves: ['host-fast', 'host-chip', 'host-guard', 'host-fast'] }, + ]), + guestParty: makeParty('guest-user', [ + { slot: 1, speciesId: '004', levelActual: 60, moves: ['guest-fast', 'guest-chip', 'guest-guard', 'guest-finisher'] }, + { slot: 2, speciesId: '007', levelActual: 58, moves: ['guest-fast', 'guest-chip', 'guest-guard', 'guest-finisher'] }, + ]), + }); + + const forfeited = service.submitCommand({ + session: created.session, + seat: 'guest', + envelope: command(1, 'awaiting_actions', 'cmd-guest-forfeit', { type: 'forfeit' }), + }); + + assert.equal(forfeited.session.phase, 'finished'); + assert.deepEqual(forfeited.session.result, { + winnerSeat: 'host', + loserSeat: 'guest', + reason: 'forfeit', + recordedAt: '2026-04-11T07:01:03.000Z', + }); + + const hostEnded = findEvent(forfeited.eventsBySeat.host, 'battle.ended'); + const guestEnded = findEvent(forfeited.eventsBySeat.guest, 'battle.ended'); + assert.equal(hostEnded.payload.result, 'win'); + assert.equal(guestEnded.payload.result, 'loss'); + assert.equal(hostEnded.payload.reason, 'forfeit'); + }); +}); diff --git a/test/pvp-client-protocol.test.ts b/test/pvp-client-protocol.test.ts new file mode 100644 index 00000000..29799e32 --- /dev/null +++ b/test/pvp-client-protocol.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { PvpWsOutboundEnvelope } from '../src/server/ws/index.js'; +import type { ViewerVisibleState } from '../src/server/battle/index.js'; +import { + applyPvpTransportEnvelope, + createPvpClientCommand, + createPvpClientState, +} from '../src/pvp/index.js'; + +const BASE_VISIBLE_STATE: ViewerVisibleState = { + self: { + active: { + slot: 1, + speciesId: '001', + nickname: 'Bulba', + levelActual: 55, + levelEffective: 52, + hp: 120, + hpMax: 120, + status: null, + fainted: false, + moves: [ + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + { slot: 2, id: 'growl', disabled: false, currentPp: 40 }, + ], + }, + bench: [ + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: false }, + ], + }, + opponent: { + active: { + slot: 1, + speciesId: '133', + nickname: 'Eevee', + levelActual: 54, + levelEffective: 52, + hp: 110, + hpMax: 110, + status: null, + fainted: false, + }, + benchCount: 2, + }, +}; + +function cloneVisibleState(): ViewerVisibleState { + return { + self: { + active: { ...BASE_VISIBLE_STATE.self.active }, + bench: BASE_VISIBLE_STATE.self.bench.map((entry) => ({ ...entry })), + }, + opponent: { + active: { ...BASE_VISIBLE_STATE.opponent.active }, + benchCount: BASE_VISIBLE_STATE.opponent.benchCount, + }, + }; +} + +function makeSnapshot(seq = 1): PvpWsOutboundEnvelope { + return { + type: 'room.snapshot', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + roomStatus: 'in_progress', + battleStatus: 'awaiting_actions', + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v1', + yourSeat: 'host', + turn: 3, + visibleState: cloneVisibleState(), + pendingRequest: { + kind: 'choose_move_or_switch', + deadlineMs: 30_000, + commandSubmitted: false, + }, + }, + }; +} + +function makeActionRequest(seq = 2): PvpWsOutboundEnvelope { + return { + type: 'battle.request_action', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + turn: 3, + phase: 'awaiting_actions', + requestId: 'req-turn-3', + deadlineMs: 25_000, + request: { + kind: 'choose_move_or_switch', + activePokemon: { ...cloneVisibleState().self.active }, + availableMoves: [ + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + { slot: 2, id: 'growl', disabled: false, currentPp: 40 }, + ], + availableSwitches: [ + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: false }, + ], + }, + }, + }; +} + +describe('pvp client protocol', () => { + it('applies battle events through the session store', () => { + const result = applyPvpTransportEnvelope(createPvpClientState(), makeSnapshot()); + + assert.equal(result.outbound.length, 0); + assert.equal(result.state.session.roomId, 'room_000001'); + assert.equal(result.state.session.pendingRequest?.kind, 'choose_move_or_switch'); + assert.equal(result.state.lastTransportMessageType, 'room.snapshot'); + assert.equal(result.state.lastTransportMessageAt, '2026-04-11T10:00:01.000Z'); + }); + + it('answers ws.ping with ws.pong and tracks timestamps', () => { + const result = applyPvpTransportEnvelope( + createPvpClientState(), + { + type: 'ws.ping', + sentAt: '2026-04-11T10:05:00.000Z', + }, + { + pongSentAt: '2026-04-11T10:05:00.123Z', + }, + ); + + assert.deepEqual(result.outbound, [{ type: 'ws.pong', sentAt: '2026-04-11T10:05:00.123Z' }]); + assert.equal(result.state.lastTransportMessageType, 'ws.ping'); + assert.equal(result.state.lastTransportMessageAt, '2026-04-11T10:05:00.000Z'); + assert.equal(result.state.lastPingAt, '2026-04-11T10:05:00.000Z'); + assert.equal(result.state.lastPongSentAt, '2026-04-11T10:05:00.123Z'); + }); + + it('records ws.error envelopes as transport errors', () => { + const result = applyPvpTransportEnvelope(createPvpClientState(), { + type: 'ws.error', + sentAt: '2026-04-11T10:06:00.000Z', + code: 'PVP_WS_BAD_MESSAGE', + message: 'invalid envelope', + retryable: true, + details: { field: 'payload.type' }, + }); + + assert.equal(result.outbound.length, 0); + assert.equal(result.state.lastTransportMessageType, 'ws.error'); + assert.equal(result.state.lastTransportError?.code, 'PVP_WS_BAD_MESSAGE'); + assert.equal(result.state.lastTransportError?.message, 'invalid envelope'); + assert.deepEqual(result.state.lastTransportError?.details, { field: 'payload.type' }); + }); + + it('creates battle.command envelopes through the session-store wrapper', () => { + const snapshot = applyPvpTransportEnvelope(createPvpClientState(), makeSnapshot()).state; + const actionable = applyPvpTransportEnvelope(snapshot, makeActionRequest()).state; + + const result = createPvpClientCommand(actionable, { + clientCommandId: 'cmd-1', + sentAt: '2026-04-11T10:00:03.000Z', + command: { + type: 'choose_move', + moveSlot: 1, + }, + }); + + assert.equal(result.envelope.type, 'battle.command'); + assert.equal(result.envelope.roomId, 'room_000001'); + assert.equal(result.envelope.battleId, 'battle_000001'); + assert.equal(result.envelope.payload.clientCommandId, 'cmd-1'); + assert.deepEqual(result.envelope.payload.command, { + type: 'choose_move', + moveSlot: 1, + }); + assert.equal(result.state.session.pendingCommand?.clientCommandId, 'cmd-1'); + assert.equal(result.state.session.pendingCommand?.status, 'created'); + assert.equal(result.state.session.nextClientSeq, 2); + }); +}); diff --git a/test/pvp-command-status-view.test.ts b/test/pvp-command-status-view.test.ts new file mode 100644 index 00000000..70952c65 --- /dev/null +++ b/test/pvp-command-status-view.test.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createPvpCommandStatusView, + createPvpCommandStatusViewFromSession, + createPvpSessionState, + type PvpSessionClientState, + type PvpSessionState, +} from '../src/pvp/index.js'; + +function buildActionRequest(overrides: Partial = {}) { + return { + kind: 'choose_move_or_switch' as const, + phase: 'awaiting_actions' as const, + turn: 12, + deadlineMs: 30_000, + commandSubmitted: false, + requestId: 'req-turn-12', + activePokemon: { + slot: 1, + speciesId: '025', + nickname: 'Pika', + levelActual: 66, + levelEffective: 60, + hp: 88, + hpMax: 120, + status: null, + fainted: false, + }, + availableMoves: [ + { slot: 1, id: 'thunderbolt', disabled: false, currentPp: 10 }, + { slot: 2, id: 'quick_attack', disabled: false, currentPp: 30 }, + ], + availableSwitches: [ + { slot: 2, speciesId: '133', nickname: 'Eevee', fainted: false }, + ], + ...overrides, + }; +} + +function createBaseSession(): PvpSessionState { + const session = createPvpSessionState(); + session.turn = 12; + session.battleStatus = 'awaiting_actions'; + session.pendingRequest = buildActionRequest(); + return session; +} + +function createClientState(session: PvpSessionState): PvpSessionClientState { + return { + transportStatus: 'connected', + session, + protocol: { + session, + lastInboundEnvelope: null, + lastOutboundEnvelope: null, + }, + reconnect: { + autoReconnectEnabled: true, + attempt: 0, + scheduled: false, + delay: null, + nextReconnectAt: null, + lastTrigger: 'manual', + }, + canSendCommand: Boolean(session.pendingRequest), + hasPendingRequest: Boolean(session.pendingRequest), + activeRequestKind: session.pendingRequest?.kind ?? null, + }; +} + +describe('pvp command status view', () => { + it('returns null for null input and renders an idle session as none state', () => { + assert.equal(createPvpCommandStatusView(null), null); + assert.equal(createPvpCommandStatusViewFromSession(null), null); + + const session = createPvpSessionState(); + const view = createPvpCommandStatusViewFromSession(session); + + assert.ok(view); + assert.equal(view.submissionState, 'none'); + assert.equal(view.statusLabel, '제출 대기 없음'); + assert.equal(view.commandSummary, null); + assert.equal(view.locked, false); + assert.equal(view.canInteract, false); + assert.equal(view.relationSummary, '활성 요청이 없어 commandSubmitted / pendingCommand 관계를 추적하지 않습니다.'); + assert.equal(view.rejection, null); + }); + + it('renders created state as waiting for battle.command_accepted', () => { + const session = createBaseSession(); + session.pendingCommand = { + clientCommandId: 'cmd-1', + turn: 12, + phase: 'awaiting_actions', + command: { type: 'choose_move', moveSlot: 1 }, + seq: 3, + sentAt: '2026-04-11T14:20:00.000Z', + status: 'created', + lockedIn: false, + }; + + const view = createPvpCommandStatusView(createClientState(session)); + + assert.ok(view); + assert.equal(view.submissionState, 'created'); + assert.equal(view.commandSummary, '기술 1번'); + assert.equal(view.statusLabel, '서버 접수 확인 대기'); + assert.equal(view.locked, true); + assert.equal(view.canInteract, false); + assert.equal(view.requestCommandSubmitted, false); + assert.equal(view.pendingCommandStatus, 'created'); + assert.equal( + view.relationSummary, + '로컬 pendingCommand는 created 상태이지만 서버의 battle.command_accepted를 아직 받지 않아 request.commandSubmitted=false 입니다.', + ); + }); + + it('renders accepted state with pendingCommand and commandSubmitted as fully consistent', () => { + const session = createBaseSession(); + session.pendingRequest = buildActionRequest({ commandSubmitted: true }); + session.pendingCommand = { + clientCommandId: 'cmd-2', + turn: 12, + phase: 'awaiting_actions', + command: { type: 'choose_switch', targetSlot: 2 }, + seq: 4, + sentAt: '2026-04-11T14:21:00.000Z', + status: 'accepted', + lockedIn: true, + }; + + const view = createPvpCommandStatusView(createClientState(session)); + + assert.ok(view); + assert.equal(view.submissionState, 'accepted'); + assert.equal(view.commandSummary, '교체 슬롯 2'); + assert.equal(view.statusLabel, '명령 접수 완료'); + assert.equal(view.detailLabel, '상대 입력/턴 해석 대기'); + assert.equal(view.locked, true); + assert.equal(view.canInteract, false); + assert.equal(view.pendingCommandStatus, 'accepted'); + assert.equal(view.pendingCommandLockedIn, true); + assert.equal( + view.relationSummary, + '서버가 명령을 접수해 pendingCommand.status=accepted, request.commandSubmitted=true 로 일치합니다.', + ); + }); + + it('renders permanently rejected state with rejection summary and locked interaction', () => { + const session = createBaseSession(); + session.pendingCommand = { + clientCommandId: 'cmd-3', + turn: 12, + phase: 'awaiting_actions', + command: { type: 'forfeit' }, + seq: 5, + sentAt: '2026-04-11T14:22:00.000Z', + status: 'rejected_permanent', + lockedIn: true, + }; + session.lastRejectedCommand = { + clientCommandId: 'cmd-3', + code: 'COMMAND_PHASE_MISMATCH', + message: '이미 다른 phase로 전환되었습니다.', + retryable: false, + }; + + const view = createPvpCommandStatusView(createClientState(session)); + + assert.ok(view); + assert.equal(view.submissionState, 'rejected_permanent'); + assert.equal(view.commandSummary, '항복'); + assert.equal(view.statusLabel, '명령 영구 거부'); + assert.equal(view.locked, true); + assert.equal(view.canInteract, false); + assert.deepEqual(view.rejection, { + code: 'COMMAND_PHASE_MISMATCH', + message: '이미 다른 phase로 전환되었습니다.', + retryable: false, + statusLabel: '재제출 불가', + summary: '재제출 불가 · COMMAND_PHASE_MISMATCH · 이미 다른 phase로 전환되었습니다.', + }); + assert.equal( + view.relationSummary, + '마지막 제출이 영구 거부되어 pendingCommand.status=rejected_permanent 입니다. 현재 요청은 잠긴 상태로 간주합니다.', + ); + }); + + it('treats request.commandSubmitted=true snapshots without pendingCommand as accepted fallback', () => { + const session = createBaseSession(); + session.pendingRequest = buildActionRequest({ commandSubmitted: true }); + + const view = createPvpCommandStatusView(createClientState(session)); + + assert.ok(view); + assert.equal(view.submissionState, 'accepted'); + assert.equal(view.commandSummary, null); + assert.equal(view.statusLabel, '이미 제출됨'); + assert.equal(view.detailLabel, '스냅샷 기준 제출 완료'); + assert.equal(view.locked, true); + assert.equal(view.canInteract, false); + assert.equal(view.pendingCommandStatus, null); + assert.equal( + view.relationSummary, + '서버 스냅샷 기준으로 request.commandSubmitted=true 이지만 로컬 pendingCommand 세부 정보는 없습니다.', + ); + }); +}); diff --git a/test/pvp-live-cli.test.ts b/test/pvp-live-cli.test.ts new file mode 100644 index 00000000..336441b2 --- /dev/null +++ b/test/pvp-live-cli.test.ts @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createPvpClientState, + createPvpSessionState, + startPvpLiveSessionCli, + type CreateBattleCommandEnvelopeOptions, + type PvpPendingRequest, + type PvpSessionBootstrapResult, + type PvpSessionClientState, + type PvpSessionTerminalCliInputListener, + type PvpSessionTerminalCliInputSource, + type PvpSessionTerminalCliScreenOutput, + type PvpSessionTerminalRunnerState, + type SendPvpSessionBattleCommandResult, +} from '../src/pvp/index.js'; +import type { RoomView } from '../src/server/projection/index.js'; + +function createBaseSessionClientState(): PvpSessionClientState { + const session = createPvpSessionState(); + const protocol = createPvpClientState(); + protocol.session = session; + + return { + transportStatus: 'connected', + session, + protocol, + reconnect: { + autoReconnectEnabled: true, + attempt: 0, + scheduled: false, + delay: null, + nextReconnectAt: null, + lastTrigger: 'manual_connect', + }, + canSendCommand: false, + hasPendingRequest: false, + activeRequestKind: null, + }; +} + +function buildActionRequest(overrides: Partial> = {}): Extract { + return { + kind: 'choose_move_or_switch', + phase: 'awaiting_actions', + turn: 1, + deadlineMs: 30_000, + commandSubmitted: false, + requestId: 'req-live-1', + activePokemon: { + slot: 1, + speciesId: '001', + nickname: 'Bulba', + levelActual: 55, + levelEffective: 55, + hp: 120, + hpMax: 120, + status: null, + fainted: false, + }, + availableMoves: [{ slot: 1, id: 'tackle', disabled: false, currentPp: 35 }], + availableSwitches: [{ slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }], + ...overrides, + }; +} + +function syncProtocolSession(state: PvpSessionClientState): void { + state.protocol.session = state.session; +} + +class FakeSessionClient { + private state: PvpSessionClientState; + + private readonly listeners = new Set<(state: PvpSessionClientState) => void>(); + + connectCalls = 0; + + readonly disconnectCalls: Array<{ code?: number; reason?: string }> = []; + + readonly sentCommands: CreateBattleCommandEnvelopeOptions[] = []; + + constructor(initialState: PvpSessionClientState) { + this.state = structuredClone(initialState); + } + + getState(): PvpSessionClientState { + return structuredClone(this.state); + } + + subscribe(listener: (state: PvpSessionClientState) => void): () => void { + this.listeners.add(listener); + listener(this.getState()); + + return () => { + this.listeners.delete(listener); + }; + } + + connect(): void { + this.connectCalls += 1; + } + + disconnect(closeInfo: { code?: number; reason?: string } = {}): PvpSessionClientState { + this.disconnectCalls.push({ ...closeInfo }); + return this.getState(); + } + + sendBattleCommand(options: CreateBattleCommandEnvelopeOptions): SendPvpSessionBattleCommandResult { + this.sentCommands.push(structuredClone(options)); + + return { + envelope: { + type: 'battle_command', + roomId: 'room-live', + battleId: 'battle-live', + clientCommandId: options.clientCommandId, + sentAt: options.sentAt, + command: structuredClone(options.command), + }, + serialized: JSON.stringify(options), + state: this.getState(), + }; + } +} + +class FakeAdapter implements PvpSessionTerminalCliInputSource, PvpSessionTerminalCliScreenOutput { + private listener: PvpSessionTerminalCliInputListener | null = null; + + private abortHandler: (() => void) | null = null; + + readonly repaints: Array<{ screen: string; state: PvpSessionTerminalRunnerState }> = []; + + subscribe(listener: PvpSessionTerminalCliInputListener): () => void { + this.listener = listener; + return () => { + this.listener = null; + }; + } + + repaint(screen: string, state: PvpSessionTerminalRunnerState): void { + this.repaints.push({ screen, state: structuredClone(state) }); + } + + setAbortHandler(handler: (() => void) | null): void { + this.abortHandler = handler; + } + + emit(token: string): void { + this.listener?.(token); + } + + triggerAbort(): void { + this.abortHandler?.(); + } +} + +function createRoomView(): RoomView { + return { + room: { + roomId: 'room-live', + code: 'ABCDE', + status: 'open', + rulesetKey: 'gen1-open', + createdAt: '2026-04-12T00:00:00.000Z', + updatedAt: '2026-04-12T00:00:00.000Z', + battleId: null, + hostPartyId: 'party-host', + guestPartyId: null, + players: [], + }, + viewer: { + playerSlot: 'host', + authToken: 'token-host', + canStartBattle: false, + canCancelRoom: true, + canJoinRoom: false, + canSelectParty: false, + isReady: true, + registeredPartyId: 'party-host', + }, + }; +} + +describe('startPvpLiveSessionCli', () => { + it('starts the live cli from a bootstrap result and disconnects on stop', async () => { + const state = createBaseSessionClientState(); + state.session.roomId = 'room-live'; + state.session.battleId = 'battle-live'; + state.session.battleStatus = 'awaiting_actions'; + state.session.pendingRequest = buildActionRequest(); + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(state); + + const sessionClient = new FakeSessionClient(state); + const adapter = new FakeAdapter(); + const bootstrap: PvpSessionBootstrapResult = { + roomView: createRoomView(), + roomId: 'room-live', + sessionClient, + }; + + const liveCli = await startPvpLiveSessionCli({ + bootstrap, + adapter, + }); + + assert.equal(sessionClient.connectCalls, 1); + assert.equal(liveCli.getState().running, true); + assert.equal(adapter.repaints.length, 1); + + adapter.emit('1'); + assert.equal(sessionClient.sentCommands.length, 1); + + await liveCli.stop(); + + assert.deepEqual(sessionClient.disconnectCalls, [{ code: 1000, reason: 'terminal_cli_stop' }]); + assert.equal(liveCli.getState().running, false); + }); +}); diff --git a/test/pvp-ops-routes.test.ts b/test/pvp-ops-routes.test.ts new file mode 100644 index 00000000..1e249f8d --- /dev/null +++ b/test/pvp-ops-routes.test.ts @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createPvpOpsRoutes } from '../src/server/http/pvp-ops-routes.js'; +import type { BattleDebugView } from '../src/server/battle/battle-types.js'; + +function makeDebugView(roomId = 'room_001'): BattleDebugView { + return { + roomId, + battleId: 'battle_001', + phase: 'waiting_for_commands', + turn: 3, + requestState: null, + commands: [], + events: [], + timeouts: { + host: { + warnings: 0, + consecutive: 0, + lastDeadlineAt: null, + lastTimedOutAt: null, + }, + guest: { + warnings: 1, + consecutive: 1, + lastDeadlineAt: '2026-04-11T09:30:00.000Z', + lastTimedOutAt: '2026-04-11T09:30:05.000Z', + }, + }, + result: null, + }; +} + +test('ops debug route는 인증이 없으면 401을 반환한다', () => { + const routes = createPvpOpsRoutes({ + getBattleDebugView: () => makeDebugView(), + }); + + const response = routes.getBattleDebug({ + params: { roomId: 'room_001' }, + }); + + assert.equal(response.status, 401); + assert.equal(response.body.error.code, 'PVP_UNAUTHORIZED'); +}); + +test('ops debug route는 operator가 아니면 403을 반환한다', () => { + const routes = createPvpOpsRoutes({ + getBattleDebugView: () => makeDebugView(), + }); + + const response = routes.getBattleDebug({ + auth: { playerId: 'player-1' }, + params: { roomId: 'room_001' }, + }); + + assert.equal(response.status, 403); + assert.equal(response.body.error.code, 'PVP_OPERATOR_FORBIDDEN'); +}); + +test('ops debug route는 roomId가 없으면 400을 반환한다', () => { + const routes = createPvpOpsRoutes({ + getBattleDebugView: () => makeDebugView(), + }); + + const response = routes.getBattleDebug({ + auth: { playerId: 'ops-user', operator: true }, + }); + + assert.equal(response.status, 400); + assert.equal(response.body.error.code, 'PVP_INVALID_REQUEST'); +}); + +test('ops debug route는 room을 찾지 못하면 404를 반환한다', () => { + const routes = createPvpOpsRoutes({ + getBattleDebugView: () => undefined, + }); + + const response = routes.getBattleDebug({ + auth: { playerId: 'ops-user', operator: true }, + params: { roomId: 'missing_room' }, + }); + + assert.equal(response.status, 404); + assert.equal(response.body.error.code, 'PVP_BATTLE_DEBUG_NOT_FOUND'); +}); + +test('ops debug route는 operator에게 battle debug view를 그대로 반환한다', () => { + const expected = makeDebugView('room_live_777'); + const routes = createPvpOpsRoutes({ + getBattleDebugView: (roomId) => { + assert.equal(roomId, 'room_live_777'); + return expected; + }, + }); + + const response = routes.getBattleDebug({ + auth: { playerId: 'ops-user', operator: true }, + params: { roomId: 'room_live_777' }, + }); + + assert.equal(response.status, 200); + assert.deepEqual(response.body, expected); +}); diff --git a/test/pvp-party-registration-service.test.ts b/test/pvp-party-registration-service.test.ts new file mode 100644 index 00000000..99cba4ff --- /dev/null +++ b/test/pvp-party-registration-service.test.ts @@ -0,0 +1,156 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + InMemoryPartySnapshotRepository, + PartyRegistrationService, + PartyRegistrationServiceError, + type ActivePartySnapshot, + type GrowthProofInput, + type OnlinePartyMemberInput, +} from '../src/server/parties/index.js'; + +function makeMember(slot: number, speciesId: string, levelActual = 50): OnlinePartyMemberInput { + return { + slot, + pokemonInstanceId: `pkm-${slot}`, + speciesId, + nickname: `P-${slot}`, + levelActual, + moves: [`move-${slot}-1`, `move-${slot}-2`, `move-${slot}-3`, `move-${slot}-4`], + }; +} + +function makeGrowthProof(members: OnlinePartyMemberInput[]): GrowthProofInput { + return { + proofVersion: 'v1', + capturedAt: '2026-04-11T09:00:00Z', + sourceSaveId: 'save_main', + sourceSaveRevision: 101, + cheatFlags: { + hasCheatHistory: false, + flags: [], + }, + memberProofs: members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId, + speciesId: member.speciesId, + levelActual: member.levelActual, + movesHash: `sha256:moves-${member.slot}`, + stateHash: `sha256:state-${member.slot}`, + })), + }; +} + +function makeMembers(): OnlinePartyMemberInput[] { + return [ + makeMember(1, '387', 12), + makeMember(2, '390', 18), + makeMember(3, '393', 24), + makeMember(4, '403', 31), + makeMember(5, '483', 72), + makeMember(6, '490', 55), + ]; +} + +function makeRegisterInput() { + const members = makeMembers(); + + return { + playerId: 'player-1', + generation: 'gen4' as const, + sourceStateHash: 'sha256:state-a', + sourceConfigHash: 'sha256:config-a', + clientBuild: 'tokenmon-cli/0.120.0', + members, + growthProof: makeGrowthProof(members), + }; +} + +test('활성 파티를 최초 등록하고 동일 입력은 멱등 처리한다', () => { + const repository = new InMemoryPartySnapshotRepository(); + const service = new PartyRegistrationService({ repository }); + const input = makeRegisterInput(); + + const firstResult = service.registerActiveParty(input); + + assert.equal(firstResult.changed, true); + assert.equal(firstResult.party.snapshotVersion, 1); + assert.match(firstResult.party.snapshotId, /^ops_gen4_\d{6}$/); + assert.equal(firstResult.party.rulesetKey, 'tkm-friendly-gen4-v1'); + assert.equal(firstResult.party.members[4].levelEffective, 55); + + const secondResult = service.registerActiveParty(input); + + assert.equal(secondResult.changed, false); + assert.equal(secondResult.party.snapshotId, firstResult.party.snapshotId); + assert.equal(secondResult.party.snapshotVersion, 1); +}); + +test('다른 입력으로 재등록하면 snapshotVersion을 증가시키고 활성 스냅샷을 교체한다', () => { + const repository = new InMemoryPartySnapshotRepository(); + const service = new PartyRegistrationService({ repository }); + const firstInput = makeRegisterInput(); + const secondInput = makeRegisterInput(); + secondInput.sourceStateHash = 'sha256:state-b'; + secondInput.members[0] = { + ...secondInput.members[0], + nickname: 'Starter', + }; + secondInput.growthProof = makeGrowthProof(secondInput.members); + + const firstResult = service.registerActiveParty(firstInput); + const secondResult = service.registerActiveParty(secondInput); + const activeParty = service.getActiveParty({ playerId: 'player-1', generation: 'gen4' }); + + assert.equal(secondResult.changed, true); + assert.notEqual(secondResult.party.snapshotId, firstResult.party.snapshotId); + assert.equal(secondResult.party.snapshotVersion, 2); + assert.equal(activeParty.snapshotId, secondResult.party.snapshotId); + assert.equal(activeParty.members[0].nickname, 'Starter'); +}); + +test('ruleset이 바뀐 활성 스냅샷은 조회 시 mismatch로 거부한다', () => { + const repository = new InMemoryPartySnapshotRepository(); + const service = new PartyRegistrationService({ repository }); + const registered = service.registerActiveParty(makeRegisterInput()); + + repository.seedSnapshots([ + { + ...registered.party, + playerId: 'player-1', + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v999', + isActive: true, + } as ActivePartySnapshot, + ]); + + assert.throws( + () => service.getActiveParty({ playerId: 'player-1', generation: 'gen4' }), + (error: unknown) => { + assert.ok(error instanceof PartyRegistrationServiceError); + assert.equal(error.code, 'PVP_RULESET_MISMATCH'); + assert.equal(error.status, 409); + return true; + }, + ); +}); + +test('검증 실패는 서비스 에러로 승격한다', () => { + const repository = new InMemoryPartySnapshotRepository(); + const service = new PartyRegistrationService({ repository }); + const input = makeRegisterInput(); + input.members[5] = makeMember(6, '387', 44); + input.growthProof = makeGrowthProof(input.members); + + assert.throws( + () => service.registerActiveParty(input), + (error: unknown) => { + assert.ok(error instanceof PartyRegistrationServiceError); + assert.equal(error.code, 'PVP_SPECIES_DUPLICATE'); + assert.equal(error.status, 422); + assert.equal(error.retryable, false); + return true; + }, + ); +}); diff --git a/test/pvp-party-validator.test.ts b/test/pvp-party-validator.test.ts new file mode 100644 index 00000000..7d86a70b --- /dev/null +++ b/test/pvp-party-validator.test.ts @@ -0,0 +1,169 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + PARTY_VALIDATION_ERROR_CODES, + validateOnlineParty, + type GrowthProofInput, + type OnlinePartyMemberInput, + type PvpGeneration, +} from '../src/server/parties/index.js'; + +function makeMember(slot: number, speciesId: string, levelActual = 50): OnlinePartyMemberInput { + return { + slot, + pokemonInstanceId: `pkm-${slot}`, + speciesId, + nickname: `P-${slot}`, + levelActual, + moves: [`move-${slot}-1`, `move-${slot}-2`, `move-${slot}-3`, `move-${slot}-4`], + }; +} + +function makeGrowthProof(members: OnlinePartyMemberInput[]): GrowthProofInput { + return { + proofVersion: 'v1', + capturedAt: '2026-04-11T08:00:00Z', + sourceSaveId: 'save_main', + sourceSaveRevision: 12, + cheatFlags: { + hasCheatHistory: false, + flags: [], + }, + memberProofs: members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId, + speciesId: member.speciesId, + levelActual: member.levelActual, + movesHash: `sha256:moves-${member.slot}`, + stateHash: `sha256:state-${member.slot}`, + })), + }; +} + +function makeRequest(generation: PvpGeneration = 'gen4') { + const members = [ + makeMember(1, '387', 12), + makeMember(2, '390', 18), + makeMember(3, '393', 24), + makeMember(4, '403', 31), + makeMember(5, '483', 72), + makeMember(6, '490', 55), + ]; + + return { + generation, + members, + growthProof: makeGrowthProof(members), + }; +} + +test('6마리 정상 파티를 정규화된 snapshot draft로 승인한다', () => { + const result = validateOnlineParty(makeRequest()); + + assert.equal(result.ok, true); + if (!result.ok) { + throw new Error(`expected success but got ${result.errorCodes.join(',')}`); + } + + assert.equal(result.snapshot.generation, 'gen4'); + assert.equal(result.snapshot.validationStatus, 'accepted'); + assert.equal(result.snapshot.partySummary.memberCount, 6); + assert.equal(result.snapshot.partySummary.legendaryMythicalCount, 2); + assert.equal(result.snapshot.partySummary.restrictedCount, 1); + assert.equal(result.snapshot.partySummary.speciesDupClause, true); + assert.deepEqual( + result.snapshot.members.map((member) => member.slot), + [1, 2, 3, 4, 5, 6], + ); + assert.equal(result.snapshot.members[4].specialClass.legendary, true); + assert.equal(result.snapshot.members[4].specialClass.restricted, true); + assert.equal(result.snapshot.members[4].levelActual, 72); + assert.equal(result.snapshot.members[4].levelEffective, 55); + assert.equal(result.snapshot.members[5].specialClass.mythical, true); +}); + +test('중복 종 파티를 거부한다', () => { + const request = makeRequest(); + request.members[5] = makeMember(6, '387', 22); + request.growthProof = makeGrowthProof(request.members); + + const result = validateOnlineParty(request); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.deepEqual(result.errorCodes, [PARTY_VALIDATION_ERROR_CODES.PVP_SPECIES_DUPLICATE]); +}); + +test('legendary + mythical 총량 2 초과를 거부한다', () => { + const request = makeRequest(); + request.members[0] = makeMember(1, '480', 50); + request.members[1] = makeMember(2, '481', 50); + request.members[2] = makeMember(3, '491', 50); + request.growthProof = makeGrowthProof(request.members); + + const result = validateOnlineParty(request); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.ok( + result.errorCodes.includes( + PARTY_VALIDATION_ERROR_CODES.PVP_SPECIAL_LIMIT_LEGENDARY_MYTHICAL_EXCEEDED, + ), + ); +}); + +test('restricted 2마리 파티를 거부한다', () => { + const request = makeRequest(); + request.members[5] = makeMember(6, '484', 66); + request.growthProof = makeGrowthProof(request.members); + + const result = validateOnlineParty(request); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.ok( + result.errorCodes.includes(PARTY_VALIDATION_ERROR_CODES.PVP_SPECIAL_LIMIT_RESTRICTED_EXCEEDED), + ); +}); + +test('치트 오염 save는 기본 거부한다', () => { + const request = makeRequest(); + request.growthProof.cheatFlags.hasCheatHistory = true; + + const result = validateOnlineParty(request); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.ok(result.errorCodes.includes(PARTY_VALIDATION_ERROR_CODES.PVP_CHEAT_SAVE_DISALLOWED)); +}); + +test('growth proof slot 불일치를 거부한다', () => { + const request = makeRequest(); + request.growthProof.memberProofs[0].slot = 2; + + const result = validateOnlineParty(request); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.ok(result.errorCodes.includes(PARTY_VALIDATION_ERROR_CODES.PVP_GROWTH_PROOF_MEMBER_MISMATCH)); +}); + +test('slot/moves/species 기본 입력 검증을 수행한다', () => { + const request = makeRequest(); + request.members[0] = { + ...request.members[0], + slot: 0, + speciesId: '99999', + moves: ['solo-move'], + }; + request.growthProof = makeGrowthProof(request.members); + + const result = validateOnlineParty(request); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.ok(result.errorCodes.includes(PARTY_VALIDATION_ERROR_CODES.PVP_PARTY_SLOT_INVALID)); + assert.ok(result.errorCodes.includes(PARTY_VALIDATION_ERROR_CODES.PVP_SPECIES_UNKNOWN)); + assert.ok(result.errorCodes.includes(PARTY_VALIDATION_ERROR_CODES.PVP_MOVES_INVALID)); +}); diff --git a/test/pvp-reconnect-controller.test.ts b/test/pvp-reconnect-controller.test.ts new file mode 100644 index 00000000..75781998 --- /dev/null +++ b/test/pvp-reconnect-controller.test.ts @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createPvpReconnectController, + createPvpWebSocketClient, + type PvpWebSocketCloseEvent, + type PvpWebSocketErrorEvent, + type PvpWebSocketLike, + type PvpWebSocketMessageEvent, +} from '../src/pvp/index.js'; + +class FakeSocket implements PvpWebSocketLike { + onopen: (() => void) | null = null; + + onmessage: ((event: PvpWebSocketMessageEvent) => void) | null = null; + + onclose: ((event: PvpWebSocketCloseEvent) => void) | null = null; + + onerror: ((event: PvpWebSocketErrorEvent) => void) | null = null; + + readonly sent: string[] = []; + + readonly closes: Array<{ code?: number; reason?: string }> = []; + + send(data: string): void { + this.sent.push(data); + } + + close(code?: number, reason?: string): void { + this.closes.push({ code, reason }); + } + + emitOpen(): void { + this.onopen?.(); + } + + emitClose(event: PvpWebSocketCloseEvent = {}): void { + this.onclose?.(event); + } + + emitError(event: PvpWebSocketErrorEvent = {}): void { + this.onerror?.(event); + } +} + +function createTimeHarness(start = '2026-04-11T11:00:00.000Z') { + let current = new Date(start).getTime(); + let nextId = 1; + const timers = new Map void }>(); + + const scheduler = { + setTimeout(callback: () => void, delayMs: number): number { + const id = nextId++; + timers.set(id, { + runAt: current + delayMs, + callback, + }); + return id; + }, + clearTimeout(handle: unknown): void { + if (typeof handle === 'number') { + timers.delete(handle); + } + }, + }; + + function advance(ms: number): void { + const target = current + ms; + + while (true) { + let nextTimer: { id: number; runAt: number; callback: () => void } | null = null; + for (const [id, timer] of timers.entries()) { + if (timer.runAt > target) { + continue; + } + + if (!nextTimer || timer.runAt < nextTimer.runAt || (timer.runAt === nextTimer.runAt && id < nextTimer.id)) { + nextTimer = { id, runAt: timer.runAt, callback: timer.callback }; + } + } + + if (!nextTimer) { + break; + } + + current = nextTimer.runAt; + timers.delete(nextTimer.id); + nextTimer.callback(); + } + + current = target; + } + + return { + now: () => new Date(current), + scheduler, + advance, + pendingTimerCount: () => timers.size, + }; +} + +describe('pvp reconnect controller', () => { + it('schedules an automatic reconnect after an unexpected close', () => { + const time = createTimeHarness(); + const sockets: FakeSocket[] = []; + const client = createPvpWebSocketClient({ + serverUrl: 'https://pvp.example.com', + roomId: 'room_000001', + token: 'auth-token', + now: time.now, + createSocket() { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + }); + const controller = createPvpReconnectController({ + client, + now: time.now, + scheduler: time.scheduler, + baseDelayMs: 1_000, + maxDelayMs: 8_000, + multiplier: 2, + }); + + controller.connect(); + sockets[0].emitOpen(); + time.advance(500); + sockets[0].emitClose({ code: 4002, reason: 'PVP_WS_HEARTBEAT_TIMEOUT', wasClean: false }); + + let state = controller.getState(); + assert.equal(state.reconnectScheduled, true); + assert.equal(state.reconnectAttempt, 1); + assert.equal(state.reconnectDelayMs, 1_000); + assert.equal(state.nextReconnectAt, '2026-04-11T11:00:01.500Z'); + assert.equal(state.client.transportStatus, 'closed'); + assert.equal(time.pendingTimerCount(), 1); + + time.advance(999); + assert.equal(sockets.length, 1); + + time.advance(1); + assert.equal(sockets.length, 2); + assert.equal(client.getState().transportStatus, 'reconnecting'); + + sockets[1].emitOpen(); + state = controller.getState(); + assert.equal(state.reconnectScheduled, false); + assert.equal(state.reconnectAttempt, 0); + assert.equal(state.reconnectDelayMs, null); + assert.equal(state.nextReconnectAt, null); + assert.equal(state.client.transportStatus, 'connected'); + }); + + it('backs off across repeated reconnect failures and resets after a successful open', () => { + const time = createTimeHarness(); + const sockets: FakeSocket[] = []; + const client = createPvpWebSocketClient({ + serverUrl: 'https://pvp.example.com', + roomId: 'room_000001', + token: 'auth-token', + now: time.now, + createSocket() { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + }); + const controller = createPvpReconnectController({ + client, + now: time.now, + scheduler: time.scheduler, + baseDelayMs: 500, + maxDelayMs: 4_000, + multiplier: 2, + }); + + controller.connect(); + sockets[0].emitOpen(); + sockets[0].emitClose({ code: 4001, reason: 'network_drop', wasClean: false }); + assert.equal(controller.getState().reconnectDelayMs, 500); + + time.advance(500); + assert.equal(sockets.length, 2); + sockets[1].emitClose({ code: 4001, reason: 'network_drop', wasClean: false }); + + let state = controller.getState(); + assert.equal(state.reconnectAttempt, 2); + assert.equal(state.reconnectDelayMs, 1_000); + + time.advance(1_000); + assert.equal(sockets.length, 3); + sockets[2].emitOpen(); + assert.equal(controller.getState().reconnectAttempt, 0); + + sockets[2].emitClose({ code: 4001, reason: 'network_drop', wasClean: false }); + state = controller.getState(); + assert.equal(state.reconnectAttempt, 1); + assert.equal(state.reconnectDelayMs, 500); + }); + + it('does not double-schedule when an error is followed by a close', () => { + const time = createTimeHarness(); + const sockets: FakeSocket[] = []; + const client = createPvpWebSocketClient({ + serverUrl: 'https://pvp.example.com', + roomId: 'room_000001', + token: 'auth-token', + now: time.now, + createSocket() { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + }); + const controller = createPvpReconnectController({ + client, + now: time.now, + scheduler: time.scheduler, + baseDelayMs: 750, + maxDelayMs: 4_000, + multiplier: 2, + }); + + controller.connect(); + sockets[0].emitOpen(); + sockets[0].emitError({ message: 'socket transport failure' }); + + const afterError = controller.getState(); + assert.equal(afterError.reconnectScheduled, true); + assert.equal(afterError.reconnectAttempt, 1); + assert.equal(time.pendingTimerCount(), 1); + + time.advance(200); + sockets[0].emitClose({ code: 4000, reason: 'abnormal_close', wasClean: false }); + + const afterClose = controller.getState(); + assert.equal(afterClose.reconnectScheduled, true); + assert.equal(afterClose.reconnectAttempt, 1); + assert.equal(time.pendingTimerCount(), 1); + + time.advance(550); + assert.equal(sockets.length, 2); + assert.equal(client.getState().transportStatus, 'reconnecting'); + }); + + it('does not reconnect after a manual disconnect', () => { + const time = createTimeHarness(); + const sockets: FakeSocket[] = []; + const client = createPvpWebSocketClient({ + serverUrl: 'https://pvp.example.com', + roomId: 'room_000001', + token: 'auth-token', + now: time.now, + createSocket() { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + }); + const controller = createPvpReconnectController({ + client, + now: time.now, + scheduler: time.scheduler, + baseDelayMs: 1_000, + maxDelayMs: 4_000, + multiplier: 2, + }); + + controller.connect(); + sockets[0].emitOpen(); + controller.disconnect({ code: 1000, reason: 'user_left_room' }); + sockets[0].emitClose({ code: 1000, reason: 'user_left_room', wasClean: true }); + + const state = controller.getState(); + assert.equal(state.autoReconnectEnabled, false); + assert.equal(state.reconnectScheduled, false); + assert.equal(state.reconnectAttempt, 0); + assert.equal(state.client.lastClose.reason, 'user_left_room'); + + time.advance(5_000); + assert.equal(sockets.length, 1); + }); +}); diff --git a/test/pvp-reconnect.test.ts b/test/pvp-reconnect.test.ts new file mode 100644 index 00000000..54be8321 --- /dev/null +++ b/test/pvp-reconnect.test.ts @@ -0,0 +1,461 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; + +import { initLocale } from '../src/i18n/index.js'; +import type { MoveData, PokemonData } from '../src/core/types.js'; +import { + BattleSessionService, + type BattleCommandEnvelope, + type BattleDataResolver, +} from '../src/server/battle/index.js'; +import { + InMemoryPartySnapshotRepository, + PartyRegistrationService, + type GrowthProofInput, + type OnlinePartyMemberInput, +} from '../src/server/parties/index.js'; +import type { PvpGeneration } from '../src/server/rules/index.js'; +import { InMemoryRoomRepository, RoomService } from '../src/server/rooms/index.js'; +import { PvpWsServer, type PvpWsOutboundEnvelope } from '../src/server/ws/index.js'; + +initLocale('ko'); + +const SPECIES_DATA: Record = { + '001': { + id: 1, + name: 'Bulbasaur', + types: ['grass'], + stage: 1, + line: ['Bulbasaur'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 80, attack: 85, defense: 80, speed: 90, sp_attack: 95, sp_defense: 85 }, + catch_rate: 45, + }, + '004': { + id: 4, + name: 'Charmander', + types: ['fire'], + stage: 1, + line: ['Charmander'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 78, attack: 84, defense: 72, speed: 88, sp_attack: 100, sp_defense: 78 }, + catch_rate: 45, + }, + '007': { + id: 7, + name: 'Squirtle', + types: ['water'], + stage: 1, + line: ['Squirtle'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 79, attack: 83, defense: 100, speed: 60, sp_attack: 85, sp_defense: 105 }, + catch_rate: 45, + }, + '025': { + id: 25, + name: 'Pikachu', + types: ['electric'], + stage: 1, + line: ['Pikachu'], + evolves_at: null, + unlock: 'starter', + exp_group: 'medium_fast', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 70, attack: 60, defense: 55, speed: 110, sp_attack: 70, sp_defense: 60 }, + catch_rate: 190, + }, + '039': { + id: 39, + name: 'Jigglypuff', + types: ['normal'], + stage: 1, + line: ['Jigglypuff'], + evolves_at: null, + unlock: 'wild', + exp_group: 'fast', + rarity: 'uncommon', + region: 'kanto', + base_stats: { hp: 135, attack: 65, defense: 45, speed: 20, sp_attack: 65, sp_defense: 50 }, + catch_rate: 170, + }, + '052': { + id: 52, + name: 'Meowth', + types: ['normal'], + stage: 1, + line: ['Meowth'], + evolves_at: 28, + unlock: 'wild', + exp_group: 'medium_fast', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 60, attack: 70, defense: 55, speed: 110, sp_attack: 45, sp_defense: 65 }, + catch_rate: 255, + }, +}; + +const MOVE_DATA: Record = { + 'host-fast': { + id: 101, + name: 'host-fast', + nameKo: '호스트 속공', + nameEn: 'Host Fast', + type: 'normal', + category: 'physical', + power: 55, + accuracy: 100, + pp: 20, + }, + 'host-chip': { + id: 102, + name: 'host-chip', + nameKo: '호스트 견제', + nameEn: 'Host Chip', + type: 'grass', + category: 'special', + power: 35, + accuracy: 100, + pp: 25, + }, + 'guest-fast': { + id: 201, + name: 'guest-fast', + nameKo: '게스트 속공', + nameEn: 'Guest Fast', + type: 'normal', + category: 'physical', + power: 50, + accuracy: 100, + pp: 20, + }, + 'guest-chip': { + id: 202, + name: 'guest-chip', + nameKo: '게스트 견제', + nameEn: 'Guest Chip', + type: 'fire', + category: 'special', + power: 30, + accuracy: 100, + pp: 25, + }, +}; + +const RESOLVER: BattleDataResolver = { + resolveSpecies(_generation, speciesId) { + return SPECIES_DATA[speciesId] ?? SPECIES_DATA[speciesId.padStart(3, '0')]; + }, + resolveMove(_generation, moveId) { + return MOVE_DATA[moveId]; + }, +}; + +class FakeTransport { + readonly messages: PvpWsOutboundEnvelope[] = []; + + readonly closes: Array<{ code: number; reason: string }> = []; + + send(message: PvpWsOutboundEnvelope): void { + this.messages.push(message); + } + + close(code: number, reason: string): void { + this.closes.push({ code, reason }); + } +} + +function makeMember(slot: number, speciesId: string, levelActual: number, moves: string[]): OnlinePartyMemberInput { + return { + slot, + pokemonInstanceId: `pkm-${slot}`, + speciesId, + nickname: `P-${slot}`, + levelActual, + moves, + }; +} + +function makeGrowthProof(members: OnlinePartyMemberInput[]): GrowthProofInput { + return { + proofVersion: 'v1', + capturedAt: '2026-04-11T09:00:00Z', + sourceSaveId: 'save_main', + sourceSaveRevision: 101, + cheatFlags: { + hasCheatHistory: false, + flags: [], + }, + memberProofs: members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId, + speciesId: member.speciesId, + levelActual: member.levelActual, + movesHash: `sha256:moves-${member.slot}`, + stateHash: `sha256:state-${member.slot}`, + })), + }; +} + +function createClock() { + let current = Date.UTC(2026, 3, 11, 8, 0, 0); + + return { + now: () => new Date(current), + advance(ms: number) { + current += ms; + }, + }; +} + +function createEnvironment() { + const clock = createClock(); + const partyRepository = new InMemoryPartySnapshotRepository(); + const roomRepository = new InMemoryRoomRepository(); + const partyService = new PartyRegistrationService({ repository: partyRepository }); + let roomCodeIndex = 0; + let battleIdIndex = 0; + let battleSeedIndex = 0; + const roomCodes = ['A7KQ2M', 'B8TR4N']; + const roomService = new RoomService({ + repository: roomRepository, + partyService, + now: clock.now, + roomCodeGenerator: () => roomCodes[roomCodeIndex++] ?? 'Z9YX8W', + battleSeedGenerator: () => `bseed_${++battleSeedIndex}`, + roomTtlMs: 15 * 60 * 1000, + }); + const battleSessionService = new BattleSessionService({ + dataResolver: RESOLVER, + now: clock.now, + battleIdGenerator: () => `battle_${String(++battleIdIndex).padStart(6, '0')}`, + }); + + const registerParty = (playerId: string, generation: PvpGeneration = 'gen1') => { + const leadMoves = + playerId === 'host-user' + ? ['host-fast', 'host-chip', 'guest-fast', 'guest-chip'] + : ['guest-fast', 'guest-chip', 'host-fast', 'host-chip']; + const members: OnlinePartyMemberInput[] = [ + makeMember(1, playerId === 'host-user' ? '001' : '004', 52, leadMoves), + makeMember(2, '007', 48, ['host-chip', 'guest-chip', 'host-fast', 'guest-fast']), + makeMember(3, '025', 40, ['guest-fast', 'host-fast', 'guest-chip', 'host-chip']), + makeMember(4, '039', 35, ['guest-chip', 'host-chip', 'guest-fast', 'host-fast']), + makeMember(5, '052', 30, ['host-fast', 'guest-fast', 'host-chip', 'guest-chip']), + makeMember(6, playerId === 'host-user' ? '004' : '001', 25, ['guest-chip', 'guest-fast', 'host-chip', 'host-fast']), + ]; + + return partyService.registerActiveParty({ + playerId, + generation, + sourceStateHash: `sha256:${playerId}:state`, + sourceConfigHash: `sha256:${playerId}:config`, + clientBuild: 'tokenmon-cli/0.120.0', + members, + growthProof: makeGrowthProof(members), + }).party; + }; + + const hostParty = registerParty('host-user'); + const guestParty = registerParty('guest-user'); + const room = roomService.createRoom({ + playerId: 'host-user', + generation: 'gen1', + visibility: 'private_friend', + }); + const joinedRoom = roomService.joinRoom({ + playerId: 'guest-user', + roomId: room.room.roomId, + roomCode: room.room.roomCode, + generation: 'gen1', + }); + + const server = new PvpWsServer({ + authenticate(token) { + if (token === 'host-token') { + return { userId: 'host-user' }; + } + if (token === 'guest-token') { + return { userId: 'guest-user' }; + } + return null; + }, + now: clock.now, + roomRepository, + battleSessionService, + loadPartySnapshot(snapshotId) { + return [hostParty, guestParty].find((party) => party.snapshotId === snapshotId); + }, + }); + + return { clock, room: joinedRoom, server }; +} + +function getBattleId(messages: PvpWsOutboundEnvelope[]): string { + const snapshot = messages.find((message) => message.type === 'room.snapshot'); + assert.ok(snapshot, 'room.snapshot message missing'); + return snapshot.battleId; +} + +function buildChooseMoveCommand(input: { + roomId: string; + battleId: string; + clientCommandId: string; + turn: number; + moveSlot: number; +}): BattleCommandEnvelope { + return { + type: 'battle.command', + roomId: input.roomId, + battleId: input.battleId, + seq: 1, + sentAt: '2026-04-11T08:00:00.000Z', + payload: { + clientCommandId: input.clientCommandId, + turn: input.turn, + phase: 'awaiting_actions', + command: { + type: 'choose_move', + moveSlot: input.moveSlot, + }, + }, + }; +} + +describe('PvP reconnect resume', () => { + beforeEach(() => { + initLocale('ko'); + }); + + it('resume reconnect 시 기존 연결을 교체하고 남은 deadline + commandSubmitted 상태를 담은 snapshot을 즉시 보낸다', () => { + const { clock, room, server } = createEnvironment(); + const hostTransport = new FakeTransport(); + const guestTransport = new FakeTransport(); + + server.connectClient({ + roomId: room.room.roomId, + token: 'host-token', + connectionId: 'conn-host-1', + transport: hostTransport, + }); + server.connectClient({ + roomId: room.room.roomId, + token: 'guest-token', + connectionId: 'conn-guest', + transport: guestTransport, + }); + + const battleId = getBattleId(hostTransport.messages); + server.receiveMessage( + 'conn-host-1', + buildChooseMoveCommand({ + roomId: room.room.roomId, + battleId, + clientCommandId: 'host-cmd-1', + turn: 1, + moveSlot: 1, + }), + ); + + clock.advance(5_000); + + const resumedTransport = new FakeTransport(); + const resumed = server.connectClient({ + roomId: room.room.roomId, + token: 'host-token', + connectionId: 'conn-host-2', + transport: resumedTransport, + resume: true, + }); + + assert.equal(resumed.seat, 'host'); + assert.equal(resumed.battleId, battleId); + assert.deepEqual(hostTransport.closes.at(-1), { + code: 4001, + reason: 'PVP_WS_CONNECTION_REPLACED', + }); + + const resumeSnapshot = resumedTransport.messages.at(-1); + assert.ok(resumeSnapshot && resumeSnapshot.type === 'room.snapshot'); + assert.equal(resumeSnapshot.payload.turn, 1); + assert.equal(resumeSnapshot.payload.pendingRequest?.kind, 'choose_move_or_switch'); + assert.equal(resumeSnapshot.payload.pendingRequest?.commandSubmitted, true); + assert.equal(resumeSnapshot.payload.pendingRequest?.deadlineMs, 40_000); + + server.receiveMessage( + 'conn-guest', + buildChooseMoveCommand({ + roomId: room.room.roomId, + battleId, + clientCommandId: 'guest-cmd-1', + turn: 1, + moveSlot: 1, + }), + ); + + assert.equal(hostTransport.messages.some((message) => message.type === 'battle.turn_resolved'), false); + assert.equal(resumedTransport.messages.some((message) => message.type === 'battle.turn_resolved'), true); + }); + + it('운영자 디버그 뷰에서 명령/이벤트 로그와 현재 timeout 상태를 조회할 수 있다', () => { + const { room, server } = createEnvironment(); + const hostTransport = new FakeTransport(); + const guestTransport = new FakeTransport(); + + server.connectClient({ + roomId: room.room.roomId, + token: 'host-token', + connectionId: 'conn-host', + transport: hostTransport, + }); + server.connectClient({ + roomId: room.room.roomId, + token: 'guest-token', + connectionId: 'conn-guest', + transport: guestTransport, + }); + + const battleId = getBattleId(hostTransport.messages); + server.receiveMessage( + 'conn-host', + buildChooseMoveCommand({ + roomId: room.room.roomId, + battleId, + clientCommandId: 'host-cmd-1', + turn: 1, + moveSlot: 1, + }), + ); + server.receiveMessage( + 'conn-guest', + buildChooseMoveCommand({ + roomId: room.room.roomId, + battleId, + clientCommandId: 'guest-cmd-1', + turn: 1, + moveSlot: 1, + }), + ); + + const debugView = server.getBattleDebugView(room.room.roomId); + assert.ok(debugView); + assert.equal(debugView?.battleId, battleId); + assert.equal(debugView?.commands.length, 2); + assert.equal(debugView?.commands[0]?.accepted, true); + assert.ok(debugView?.events.some((event) => event.type === 'battle.turn_resolved')); + assert.equal(debugView?.timeouts.host.consecutive, 0); + assert.equal(debugView?.requestState?.kind, 'choose_move_or_switch'); + }); +}); diff --git a/test/pvp-room-http-client.test.ts b/test/pvp-room-http-client.test.ts new file mode 100644 index 00000000..c2733980 --- /dev/null +++ b/test/pvp-room-http-client.test.ts @@ -0,0 +1,233 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + PvpRoomHttpClientError, + createPvpRoomHttpClient, + type PvpFetchLikeResponse, +} from '../src/pvp/index.js'; +import type { RoomView } from '../src/server/index.js'; + +const ROOM_VIEW: RoomView = { + room: { + roomId: 'room_000001', + roomCode: 'A7KQ2M', + mode: 'friendly_private', + status: 'awaiting_presence', + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v1', + createdAt: '2026-04-11T07:10:00.000Z', + expiresAt: '2026-04-11T07:25:00.000Z', + }, + you: { + seat: 'host', + partySnapshotId: 'party_000001', + partyValidationStatus: 'accepted', + presence: 'connected', + battleReady: true, + }, + opponent: { + seat: 'guest', + presence: 'offline', + battleReady: true, + displayName: 'player-guest', + }, + match: { + freezeStatus: 'pending_presence', + battleId: null, + battleStartedAt: null, + }, +}; + +function createJsonResponse(status: number, body: unknown): PvpFetchLikeResponse { + return { + status, + async json() { + return structuredClone(body); + }, + }; +} + +describe('pvp room http client', () => { + it('createRoom posts JSON with auth headers and returns RoomView', async () => { + const requests: Array<{ url: string; init?: unknown }> = []; + const client = createPvpRoomHttpClient({ + serverUrl: 'https://pvp.example.com', + fetch: async (url, init) => { + requests.push({ url, init }); + return createJsonResponse(200, ROOM_VIEW); + }, + }); + + const roomView = await client.createRoom({ + authToken: 'auth-token', + generation: 'gen4', + visibility: 'private_friend', + rulesetKey: 'tkm-friendly-gen4-v1', + }); + + assert.deepEqual(roomView, ROOM_VIEW); + assert.equal(requests.length, 1); + assert.deepEqual(requests[0], { + url: 'https://pvp.example.com/api/pvp/rooms', + init: { + method: 'POST', + headers: { + accept: 'application/json', + authorization: 'Bearer auth-token', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + generation: 'gen4', + visibility: 'private_friend', + rulesetKey: 'tkm-friendly-gen4-v1', + }), + }, + }); + }); + + it('joinRoom posts to the room join route with room id and code', async () => { + const requests: Array<{ url: string; init?: unknown }> = []; + const client = createPvpRoomHttpClient({ + serverUrl: 'https://pvp.example.com', + fetch: async (url, init) => { + requests.push({ url, init }); + return createJsonResponse(200, ROOM_VIEW); + }, + }); + + await client.joinRoom({ + authToken: 'guest-token', + roomId: 'room_000001', + roomCode: 'A7KQ2M', + generation: 'gen4', + }); + + assert.equal(requests.length, 1); + assert.deepEqual(requests[0], { + url: 'https://pvp.example.com/api/pvp/rooms/room_000001/join', + init: { + method: 'POST', + headers: { + accept: 'application/json', + authorization: 'Bearer guest-token', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + roomCode: 'A7KQ2M', + generation: 'gen4', + }), + }, + }); + }); + + it('getRoom fetches the room projection without a request body', async () => { + const requests: Array<{ url: string; init?: unknown }> = []; + const client = createPvpRoomHttpClient({ + serverUrl: 'https://pvp.example.com', + fetch: async (url, init) => { + requests.push({ url, init }); + return createJsonResponse(200, ROOM_VIEW); + }, + }); + + const roomView = await client.getRoom({ + authToken: 'viewer-token', + roomId: 'room_000001', + }); + + assert.deepEqual(roomView, ROOM_VIEW); + assert.deepEqual(requests[0], { + url: 'https://pvp.example.com/api/pvp/rooms/room_000001', + init: { + method: 'GET', + headers: { + accept: 'application/json', + authorization: 'Bearer viewer-token', + }, + }, + }); + }); + + it('surfaces non-2xx error envelopes as typed http errors', async () => { + const client = createPvpRoomHttpClient({ + serverUrl: 'https://pvp.example.com', + fetch: async () => createJsonResponse(409, { + error: { + code: 'PVP_ROOM_CODE_MISMATCH', + message: 'Room code does not match.', + retryable: false, + details: { + roomId: 'room_000001', + }, + }, + }), + }); + + await assert.rejects( + () => client.joinRoom({ + authToken: 'guest-token', + roomId: 'room_000001', + roomCode: 'WRONG1', + generation: 'gen4', + }), + (error: unknown) => { + assert.ok(error instanceof PvpRoomHttpClientError); + assert.equal(error.kind, 'http_error'); + assert.equal(error.operation, 'join_room'); + assert.equal(error.status, 409); + assert.equal(error.code, 'PVP_ROOM_CODE_MISMATCH'); + assert.equal(error.retryable, false); + assert.deepEqual(error.details, { roomId: 'room_000001' }); + return true; + }, + ); + }); + + it('treats malformed success payloads as invalid client responses', async () => { + const client = createPvpRoomHttpClient({ + serverUrl: 'https://pvp.example.com', + fetch: async () => createJsonResponse(200, { + room: { + roomId: 'room_000001', + }, + }), + }); + + await assert.rejects( + () => client.getRoom({ authToken: 'viewer-token', roomId: 'room_000001' }), + (error: unknown) => { + assert.ok(error instanceof PvpRoomHttpClientError); + assert.equal(error.kind, 'invalid_response'); + assert.equal(error.operation, 'get_room'); + assert.equal(error.status, 200); + assert.equal(error.code, 'PVP_ROOM_HTTP_INVALID_RESPONSE'); + assert.equal(error.retryable, false); + return true; + }, + ); + }); + + it('wraps fetch rejections as network errors', async () => { + const client = createPvpRoomHttpClient({ + serverUrl: 'https://pvp.example.com', + fetch: async () => { + throw new Error('socket hang up'); + }, + }); + + await assert.rejects( + () => client.getRoom({ authToken: 'viewer-token', roomId: 'room_000001' }), + (error: unknown) => { + assert.ok(error instanceof PvpRoomHttpClientError); + assert.equal(error.kind, 'network_error'); + assert.equal(error.operation, 'get_room'); + assert.equal(error.status, null); + assert.equal(error.code, 'PVP_ROOM_HTTP_NETWORK_ERROR'); + assert.equal(error.retryable, true); + assert.equal(error.cause instanceof Error, true); + return true; + }, + ); + }); +}); diff --git a/test/pvp-room-routes.test.ts b/test/pvp-room-routes.test.ts new file mode 100644 index 00000000..e6f94607 --- /dev/null +++ b/test/pvp-room-routes.test.ts @@ -0,0 +1,241 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createPvpRoomRoutes } from '../src/server/http/pvp-room-routes.js'; +import { + InMemoryPartySnapshotRepository, + PartyRegistrationService, + InMemoryRoomRepository, + RoomService, + type GrowthProofInput, + type OnlinePartyMemberInput, +} from '../src/server/index.js'; + +function makeMember(slot: number, speciesId: string, levelActual = 50): OnlinePartyMemberInput { + return { + slot, + pokemonInstanceId: `pkm-${slot}`, + speciesId, + nickname: `P-${slot}`, + levelActual, + moves: [`move-${slot}-1`, `move-${slot}-2`, `move-${slot}-3`, `move-${slot}-4`], + }; +} + +function makeMembers(): OnlinePartyMemberInput[] { + return [ + makeMember(1, '387', 12), + makeMember(2, '390', 18), + makeMember(3, '393', 24), + makeMember(4, '403', 31), + makeMember(5, '483', 72), + makeMember(6, '490', 55), + ]; +} + +function makeGrowthProof(members: OnlinePartyMemberInput[]): GrowthProofInput { + return { + proofVersion: 'v1', + capturedAt: '2026-04-11T09:00:00Z', + sourceSaveId: 'save_main', + sourceSaveRevision: 101, + cheatFlags: { + hasCheatHistory: false, + flags: [], + }, + memberProofs: members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId, + speciesId: member.speciesId, + levelActual: member.levelActual, + movesHash: `sha256:moves-${member.slot}`, + stateHash: `sha256:state-${member.slot}`, + })), + }; +} + +function registerParty(service: PartyRegistrationService, playerId: string, generation = 'gen4') { + const members = makeMembers(); + return service.registerActiveParty({ + playerId, + generation, + sourceStateHash: `sha256:state-${playerId}`, + sourceConfigHash: `sha256:config-${playerId}`, + clientBuild: 'tokenmon-cli/0.120.0', + members, + growthProof: makeGrowthProof(members), + }); +} + +function createRoutes() { + const partyRepository = new InMemoryPartySnapshotRepository(); + const roomRepository = new InMemoryRoomRepository(); + const partyService = new PartyRegistrationService({ repository: partyRepository }); + let tick = 0; + let roomCodeIndex = 0; + let battleSeedIndex = 0; + const roomCodes = ['A7KQ2M', 'B8TR4N', 'C9UV5P']; + const roomService = new RoomService({ + repository: roomRepository, + partyService, + now: () => new Date(Date.UTC(2026, 3, 11, 7, 10, tick++)), + roomCodeGenerator: () => roomCodes[Math.min(roomCodeIndex++, roomCodes.length - 1)], + battleSeedGenerator: () => `bseed_test_${++battleSeedIndex}`, + roomTtlMs: 15 * 60 * 1000, + }); + + return { + partyService, + roomService, + roomRoutes: createPvpRoomRoutes({ service: roomService }), + }; +} + +test('룸 생성은 인증이 없으면 401을 반환한다', () => { + const { roomRoutes } = createRoutes(); + + const response = roomRoutes.createRoom({ + body: { + generation: 'gen4', + visibility: 'private_friend', + }, + }); + + assert.equal(response.status, 401); + assert.equal(response.body.error.code, 'PVP_UNAUTHORIZED'); +}); + +test('shape이 잘못된 룸 생성 요청은 400을 반환한다', () => { + const { roomRoutes } = createRoutes(); + + const response = roomRoutes.createRoom({ + auth: { playerId: 'player-1' }, + body: { + visibility: 'private_friend', + }, + }); + + assert.equal(response.status, 400); + assert.equal(response.body.error.code, 'PVP_INVALID_REQUEST'); +}); + +test('룸 생성은 host 기준 projection을 반환한다', () => { + const { partyService, roomRoutes } = createRoutes(); + const registration = registerParty(partyService, 'player-host'); + + const response = roomRoutes.createRoom({ + auth: { playerId: 'player-host' }, + body: { + generation: 'gen4', + visibility: 'private_friend', + rulesetKey: 'tkm-friendly-gen4-v1', + }, + }); + + assert.equal(response.status, 200); + assert.equal(response.body.room.roomCode, 'A7KQ2M'); + assert.equal(response.body.room.status, 'waiting_for_opponent'); + assert.equal(response.body.you.seat, 'host'); + assert.equal(response.body.you.partySnapshotId, registration.party.snapshotId); + assert.equal(response.body.opponent, null); + assert.deepEqual(response.body.match, { + freezeStatus: 'waiting_for_opponent', + battleId: null, + battleStartedAt: null, + }); +}); + +test('룸 참가와 조회는 viewer별 projection을 반환하고 상대 snapshot은 숨긴다', () => { + const { partyService, roomRoutes } = createRoutes(); + const hostRegistration = registerParty(partyService, 'player-host'); + const guestRegistration = registerParty(partyService, 'player-guest'); + + const created = roomRoutes.createRoom({ + auth: { playerId: 'player-host' }, + body: { + generation: 'gen4', + visibility: 'private_friend', + }, + }); + + const joined = roomRoutes.joinRoom({ + auth: { playerId: 'player-guest' }, + params: { roomId: created.body.room.roomId }, + body: { + roomCode: created.body.room.roomCode, + generation: 'gen4', + }, + }); + + assert.equal(joined.status, 200); + assert.equal(joined.body.room.status, 'awaiting_presence'); + assert.equal(joined.body.you.seat, 'guest'); + assert.equal(joined.body.you.partySnapshotId, guestRegistration.party.snapshotId); + assert.equal(joined.body.opponent?.seat, 'host'); + assert.equal(joined.body.opponent?.displayName, 'player-host'); + assert.equal('partySnapshotId' in joined.body.opponent, false); + assert.deepEqual(joined.body.match, { + freezeStatus: 'pending_presence', + battleId: null, + battleStartedAt: null, + }); + + const hostView = roomRoutes.getRoom({ + auth: { playerId: 'player-host' }, + params: { roomId: created.body.room.roomId }, + }); + + assert.equal(hostView.status, 200); + assert.equal(hostView.body.you.seat, 'host'); + assert.equal(hostView.body.you.partySnapshotId, hostRegistration.party.snapshotId); + assert.equal(hostView.body.opponent?.seat, 'guest'); + assert.equal(hostView.body.opponent?.displayName, 'player-guest'); + assert.equal('partySnapshotId' in hostView.body.opponent, false); +}); + +test('룸 조회는 참여자가 아니면 403을 반환한다', () => { + const { partyService, roomRoutes } = createRoutes(); + registerParty(partyService, 'player-host'); + + const created = roomRoutes.createRoom({ + auth: { playerId: 'player-host' }, + body: { + generation: 'gen4', + visibility: 'private_friend', + }, + }); + + const response = roomRoutes.getRoom({ + auth: { playerId: 'player-other' }, + params: { roomId: created.body.room.roomId }, + }); + + assert.equal(response.status, 403); + assert.equal(response.body.error.code, 'PVP_ROOM_ACCESS_DENIED'); +}); + +test('룸 참가 실패는 room service error envelope을 그대로 노출한다', () => { + const { partyService, roomRoutes } = createRoutes(); + registerParty(partyService, 'player-host'); + registerParty(partyService, 'player-guest'); + + const created = roomRoutes.createRoom({ + auth: { playerId: 'player-host' }, + body: { + generation: 'gen4', + visibility: 'private_friend', + }, + }); + + const response = roomRoutes.joinRoom({ + auth: { playerId: 'player-guest' }, + params: { roomId: created.body.room.roomId }, + body: { + roomCode: 'WRONG1', + generation: 'gen4', + }, + }); + + assert.equal(response.status, 409); + assert.equal(response.body.error.code, 'PVP_ROOM_CODE_MISMATCH'); +}); diff --git a/test/pvp-room-service.test.ts b/test/pvp-room-service.test.ts new file mode 100644 index 00000000..01e6bd5f --- /dev/null +++ b/test/pvp-room-service.test.ts @@ -0,0 +1,353 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + InMemoryPartySnapshotRepository, + PartyRegistrationService, + type ActivePartySnapshot, + type GrowthProofInput, + type OnlinePartyMemberInput, +} from '../src/server/parties/index.js'; +import { + InMemoryRoomRepository, + RoomService, + RoomServiceError, + type BattleRoomRecord, +} from '../src/server/rooms/index.js'; + +function makeMember(slot: number, speciesId: string, levelActual = 50): OnlinePartyMemberInput { + return { + slot, + pokemonInstanceId: `pkm-${slot}`, + speciesId, + nickname: `P-${slot}`, + levelActual, + moves: [`move-${slot}-1`, `move-${slot}-2`, `move-${slot}-3`, `move-${slot}-4`], + }; +} + +function makeMembers(): OnlinePartyMemberInput[] { + return [ + makeMember(1, '387', 12), + makeMember(2, '390', 18), + makeMember(3, '393', 24), + makeMember(4, '403', 31), + makeMember(5, '483', 72), + makeMember(6, '490', 55), + ]; +} + +function makeGrowthProof(members: OnlinePartyMemberInput[]): GrowthProofInput { + return { + proofVersion: 'v1', + capturedAt: '2026-04-11T09:00:00Z', + sourceSaveId: 'save_main', + sourceSaveRevision: 101, + cheatFlags: { + hasCheatHistory: false, + flags: [], + }, + memberProofs: members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId, + speciesId: member.speciesId, + levelActual: member.levelActual, + movesHash: `sha256:moves-${member.slot}`, + stateHash: `sha256:state-${member.slot}`, + })), + }; +} + +function registerParty( + service: PartyRegistrationService, + playerId: string, + overrides: Partial<{ generation: 'gen4'; sourceStateHash: string; sourceConfigHash: string }> = {}, +) { + const members = makeMembers(); + + return service.registerActiveParty({ + playerId, + generation: overrides.generation ?? 'gen4', + sourceStateHash: overrides.sourceStateHash ?? `sha256:${playerId}:state`, + sourceConfigHash: overrides.sourceConfigHash ?? `sha256:${playerId}:config`, + clientBuild: 'tokenmon-cli/0.120.0', + members, + growthProof: makeGrowthProof(members), + }); +} + +function createServices() { + const partyRepository = new InMemoryPartySnapshotRepository(); + const roomRepository = new InMemoryRoomRepository(); + const partyService = new PartyRegistrationService({ repository: partyRepository }); + let tick = 0; + const roomCodes = ['A7KQ2M', 'A7KQ2M', 'B8TR4N', 'C9UV5P']; + let roomCodeIndex = 0; + let seedIndex = 0; + const roomService = new RoomService({ + repository: roomRepository, + partyService, + now: () => new Date(Date.UTC(2026, 3, 11, 7, 10, tick++)), + roomCodeGenerator: () => roomCodes[roomCodeIndex++] ?? 'Z9YX8W', + battleSeedGenerator: () => `bseed_test_${++seedIndex}`, + roomTtlMs: 15 * 60 * 1000, + }); + + return { partyRepository, roomRepository, partyService, roomService }; +} + +test('룸 생성은 host 바인딩과 고유 room code를 저장한다', () => { + const { partyService, roomService } = createServices(); + registerParty(partyService, 'host-user'); + registerParty(partyService, 'other-user'); + + const firstRoom = roomService.createRoom({ + playerId: 'host-user', + generation: 'gen4', + visibility: 'private_friend', + rulesetKey: 'tkm-friendly-gen4-v1', + }); + const secondRoom = roomService.createRoom({ + playerId: 'other-user', + generation: 'gen4', + visibility: 'private_friend', + }); + + assert.equal(firstRoom.room.roomId, 'room_000001'); + assert.equal(firstRoom.room.roomCode, 'A7KQ2M'); + assert.equal(firstRoom.room.status, 'waiting_for_opponent'); + assert.equal(firstRoom.room.expiresAt, '2026-04-11T07:25:00.000Z'); + assert.equal(firstRoom.host.userId, 'host-user'); + assert.equal(firstRoom.host.seat, 'host'); + assert.equal(firstRoom.host.partySnapshotVersion, 1); + assert.equal(firstRoom.host.battleReady, false); + assert.equal(firstRoom.guest, null); + assert.equal(firstRoom.battleFreeze, null); + + assert.equal(secondRoom.room.roomId, 'room_000002'); + assert.equal(secondRoom.room.roomCode, 'B8TR4N'); +}); + +test('룸 참가 시 generation/ruleset 검증 후 awaiting_presence와 freeze를 준비한다', () => { + const { partyService, roomService } = createServices(); + const hostParty = registerParty(partyService, 'host-user').party; + const guestParty = registerParty(partyService, 'guest-user').party; + + const createdRoom = roomService.createRoom({ + playerId: 'host-user', + generation: 'gen4', + visibility: 'private_friend', + }); + const joinedRoom = roomService.joinRoom({ + playerId: 'guest-user', + roomId: createdRoom.room.roomId, + roomCode: createdRoom.room.roomCode.toLowerCase(), + generation: 'gen4', + }); + const persistedRoom = roomService.getRoom(createdRoom.room.roomId); + + assert.equal(joinedRoom.room.status, 'awaiting_presence'); + assert.equal(joinedRoom.room.expiresAt, null); + assert.equal(joinedRoom.host.battleReady, true); + assert.equal(joinedRoom.guest?.userId, 'guest-user'); + assert.equal(joinedRoom.guest?.partySnapshotId, guestParty.snapshotId); + assert.equal(joinedRoom.guest?.battleReady, true); + assert.equal(joinedRoom.battleFreeze?.freezeStatus, 'pending_presence'); + assert.equal(joinedRoom.battleFreeze?.generation, 'gen4'); + assert.equal(joinedRoom.battleFreeze?.rulesetKey, 'tkm-friendly-gen4-v1'); + assert.equal(joinedRoom.battleFreeze?.hostPartySnapshotId, hostParty.snapshotId); + assert.equal(joinedRoom.battleFreeze?.guestPartySnapshotId, guestParty.snapshotId); + assert.equal(joinedRoom.battleFreeze?.battleSeed, 'bseed_test_1'); + assert.match(joinedRoom.battleFreeze?.rulesetHash ?? '', /^sha256:[a-f0-9]{64}$/); + assert.deepEqual(persistedRoom, joinedRoom); +}); + +test('이미 다른 활성 룸에 묶인 플레이어는 새 룸을 만들거나 참가할 수 없다', () => { + const { partyService, roomService } = createServices(); + registerParty(partyService, 'host-user'); + registerParty(partyService, 'guest-user'); + registerParty(partyService, 'third-user'); + + const room = roomService.createRoom({ + playerId: 'host-user', + generation: 'gen4', + visibility: 'private_friend', + }); + + assert.throws( + () => + roomService.createRoom({ + playerId: 'host-user', + generation: 'gen4', + visibility: 'private_friend', + }), + (error: unknown) => { + assert.ok(error instanceof RoomServiceError); + assert.equal(error.code, 'PVP_ROOM_ALREADY_BOUND'); + return true; + }, + ); + + assert.throws( + () => + roomService.joinRoom({ + playerId: 'host-user', + roomId: room.room.roomId, + roomCode: room.room.roomCode, + generation: 'gen4', + }), + (error: unknown) => { + assert.ok(error instanceof RoomServiceError); + assert.equal(error.code, 'PVP_ROOM_SELF_JOIN_FORBIDDEN'); + return true; + }, + ); + + const secondRoom = roomService.createRoom({ + playerId: 'third-user', + generation: 'gen4', + visibility: 'private_friend', + }); + + roomService.joinRoom({ + playerId: 'guest-user', + roomId: room.room.roomId, + roomCode: room.room.roomCode, + generation: 'gen4', + }); + + assert.throws( + () => + roomService.joinRoom({ + playerId: 'guest-user', + roomId: secondRoom.room.roomId, + roomCode: secondRoom.room.roomCode, + generation: 'gen4', + }), + (error: unknown) => { + assert.ok(error instanceof RoomServiceError); + assert.equal(error.code, 'PVP_ROOM_ALREADY_BOUND'); + return true; + }, + ); +}); + +test('generation/ruleset mismatch와 active snapshot 부재를 차단한다', () => { + const { partyRepository, partyService, roomService } = createServices(); + const hostParty = registerParty(partyService, 'host-user').party; + registerParty(partyService, 'guest-user'); + + const room = roomService.createRoom({ + playerId: 'host-user', + generation: 'gen4', + visibility: 'private_friend', + }); + + assert.throws( + () => + roomService.joinRoom({ + playerId: 'guest-user', + roomId: room.room.roomId, + roomCode: room.room.roomCode, + generation: 'gen5', + }), + (error: unknown) => { + assert.ok(error instanceof RoomServiceError); + assert.equal(error.code, 'PVP_ROOM_GENERATION_MISMATCH'); + return true; + }, + ); + + partyRepository.seedSnapshots([ + { + ...hostParty, + playerId: 'guest-user', + snapshotId: 'ops_gen4_999999', + snapshotVersion: 9, + rulesetKey: 'tkm-friendly-gen4-v999', + isActive: true, + } as ActivePartySnapshot, + ]); + + assert.throws( + () => + roomService.joinRoom({ + playerId: 'guest-user', + roomId: room.room.roomId, + roomCode: room.room.roomCode, + generation: 'gen4', + }), + (error: unknown) => { + assert.ok(error instanceof RoomServiceError); + assert.equal(error.code, 'PVP_ROOM_RULESET_MISMATCH'); + return true; + }, + ); + + assert.throws( + () => + roomService.createRoom({ + playerId: 'missing-user', + generation: 'gen4', + visibility: 'private_friend', + }), + (error: unknown) => { + assert.ok(error instanceof RoomServiceError); + assert.equal(error.code, 'PVP_PARTY_NOT_REGISTERED'); + return true; + }, + ); +}); + +test('룸 상태와 코드 검증으로 잘못된 참가를 막는다', () => { + const { partyService, roomRepository, roomService } = createServices(); + registerParty(partyService, 'host-user'); + registerParty(partyService, 'guest-user'); + + const room = roomService.createRoom({ + playerId: 'host-user', + generation: 'gen4', + visibility: 'private_friend', + }); + + assert.throws( + () => + roomService.joinRoom({ + playerId: 'guest-user', + roomId: room.room.roomId, + roomCode: 'WRONG1', + generation: 'gen4', + }), + (error: unknown) => { + assert.ok(error instanceof RoomServiceError); + assert.equal(error.code, 'PVP_ROOM_CODE_MISMATCH'); + return true; + }, + ); + + const persisted = roomService.getRoom(room.room.roomId); + const seededRoom: BattleRoomRecord = { + ...persisted, + room: { + ...persisted.room, + status: 'cancelled', + cancelledAt: '2026-04-11T07:15:00.000Z', + }, + }; + roomRepository.seedRooms([seededRoom]); + + assert.throws( + () => + roomService.joinRoom({ + playerId: 'guest-user', + roomId: room.room.roomId, + roomCode: room.room.roomCode, + generation: 'gen4', + }), + (error: unknown) => { + assert.ok(error instanceof RoomServiceError); + assert.equal(error.code, 'PVP_ROOM_STATE_INVALID'); + return true; + }, + ); +}); diff --git a/test/pvp-routes.test.ts b/test/pvp-routes.test.ts new file mode 100644 index 00000000..2f185331 --- /dev/null +++ b/test/pvp-routes.test.ts @@ -0,0 +1,196 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createPvpPartyRoutes } from '../src/server/http/pvp-party-routes.js'; +import { createPvpRulesRoutes } from '../src/server/http/pvp-rules-routes.js'; +import { + InMemoryPartySnapshotRepository, + PartyRegistrationService, + type GrowthProofInput, + type OnlinePartyMemberInput, +} from '../src/server/parties/index.js'; + +function makeMember(slot: number, speciesId: string, levelActual = 50): OnlinePartyMemberInput { + return { + slot, + pokemonInstanceId: `pkm-${slot}`, + speciesId, + nickname: `P-${slot}`, + levelActual, + moves: [`move-${slot}-1`, `move-${slot}-2`, `move-${slot}-3`, `move-${slot}-4`], + }; +} + +function makeGrowthProof(members: OnlinePartyMemberInput[]): GrowthProofInput { + return { + proofVersion: 'v1', + capturedAt: '2026-04-11T09:00:00Z', + sourceSaveId: 'save_main', + sourceSaveRevision: 101, + cheatFlags: { + hasCheatHistory: false, + flags: [], + }, + memberProofs: members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId, + speciesId: member.speciesId, + levelActual: member.levelActual, + movesHash: `sha256:moves-${member.slot}`, + stateHash: `sha256:state-${member.slot}`, + })), + }; +} + +function makeMembers(): OnlinePartyMemberInput[] { + return [ + makeMember(1, '387', 12), + makeMember(2, '390', 18), + makeMember(3, '393', 24), + makeMember(4, '403', 31), + makeMember(5, '483', 72), + makeMember(6, '490', 55), + ]; +} + +function createRoutes() { + const repository = new InMemoryPartySnapshotRepository(); + const service = new PartyRegistrationService({ repository }); + + return { + rulesRoutes: createPvpRulesRoutes({ service }), + partyRoutes: createPvpPartyRoutes({ service }), + }; +} + +test('ruleset 조회는 인증이 없으면 401을 반환한다', () => { + const { rulesRoutes } = createRoutes(); + + const response = rulesRoutes.getRuleset({ + params: { generation: 'gen4' }, + }); + + assert.equal(response.status, 401); + assert.equal(response.body.error.code, 'PVP_UNAUTHORIZED'); +}); + +test('ruleset 조회는 현재 활성 ruleset summary를 반환한다', () => { + const { rulesRoutes } = createRoutes(); + + const response = rulesRoutes.getRuleset({ + auth: { playerId: 'player-1' }, + params: { generation: 'gen4' }, + }); + + assert.equal(response.status, 200); + assert.equal(response.body.generation, 'gen4'); + assert.equal(response.body.rulesetKey, 'tkm-friendly-gen4-v1'); +}); + +test('활성 파티가 없으면 404 envelope를 반환한다', () => { + const { partyRoutes } = createRoutes(); + + const response = partyRoutes.getActiveParty({ + auth: { playerId: 'player-1' }, + params: { generation: 'gen4' }, + }); + + assert.equal(response.status, 404); + assert.equal(response.body.error.code, 'PVP_ACTIVE_PARTY_NOT_FOUND'); +}); + +test('활성 파티 등록 후 조회할 수 있다', () => { + const { partyRoutes } = createRoutes(); + const members = makeMembers(); + + const putResponse = partyRoutes.putActiveParty({ + auth: { playerId: 'player-1' }, + params: { generation: 'gen4' }, + body: { + sourceStateHash: 'sha256:state-a', + sourceConfigHash: 'sha256:config-a', + clientBuild: 'tokenmon-cli/0.120.0', + members, + growthProof: makeGrowthProof(members), + }, + }); + + assert.equal(putResponse.status, 200); + assert.equal(putResponse.body.changed, true); + assert.equal(putResponse.body.party.snapshotVersion, 1); + + const getResponse = partyRoutes.getActiveParty({ + auth: { playerId: 'player-1' }, + params: { generation: 'gen4' }, + }); + + assert.equal(getResponse.status, 200); + assert.equal(getResponse.body.party.snapshotId, putResponse.body.party.snapshotId); + assert.equal(getResponse.body.party.members[4].levelEffective, 55); +}); + +test('같은 입력 재등록은 changed false를 반환한다', () => { + const { partyRoutes } = createRoutes(); + const members = makeMembers(); + const body = { + sourceStateHash: 'sha256:state-a', + sourceConfigHash: 'sha256:config-a', + clientBuild: 'tokenmon-cli/0.120.0', + members, + growthProof: makeGrowthProof(members), + }; + + partyRoutes.putActiveParty({ + auth: { playerId: 'player-1' }, + params: { generation: 'gen4' }, + body, + }); + + const secondResponse = partyRoutes.putActiveParty({ + auth: { playerId: 'player-1' }, + params: { generation: 'gen4' }, + body, + }); + + assert.equal(secondResponse.status, 200); + assert.equal(secondResponse.body.changed, false); + assert.equal(secondResponse.body.party.snapshotVersion, 1); +}); + +test('검증 실패는 422 error envelope로 노출한다', () => { + const { partyRoutes } = createRoutes(); + const members = makeMembers(); + members[5] = makeMember(6, '387', 55); + + const response = partyRoutes.putActiveParty({ + auth: { playerId: 'player-1' }, + params: { generation: 'gen4' }, + body: { + sourceStateHash: 'sha256:state-a', + sourceConfigHash: 'sha256:config-a', + clientBuild: 'tokenmon-cli/0.120.0', + members, + growthProof: makeGrowthProof(members), + }, + }); + + assert.equal(response.status, 422); + assert.equal(response.body.error.code, 'PVP_SPECIES_DUPLICATE'); + assert.equal(response.body.error.retryable, false); +}); + +test('shape이 잘못된 등록 요청은 400을 반환한다', () => { + const { partyRoutes } = createRoutes(); + + const response = partyRoutes.putActiveParty({ + auth: { playerId: 'player-1' }, + params: { generation: 'gen4' }, + body: { + sourceStateHash: 'sha256:state-a', + members: [], + }, + }); + + assert.equal(response.status, 400); + assert.equal(response.body.error.code, 'PVP_INVALID_REQUEST'); +}); diff --git a/test/pvp-ruleset.test.ts b/test/pvp-ruleset.test.ts new file mode 100644 index 00000000..b63f9928 --- /dev/null +++ b/test/pvp-ruleset.test.ts @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + computeEffectiveLevel, + getRulesetByGeneration, + isRestrictedSpecies, +} from '../src/server/rules/index.js'; + +test('gen4 ruleset 조회 성공', () => { + const ruleset = getRulesetByGeneration('gen4'); + + assert.equal(ruleset.generation, 'gen4'); + assert.equal(ruleset.rulesetKey, 'tkm-friendly-gen4-v1'); + assert.deepEqual(ruleset.specialLimits, { + legendaryMythicalTotal: 2, + restrictedTotal: 1, + }); + assert.deepEqual(ruleset.battlePolicy, { + format: 'single', + teamPreview: false, + leadSelection: 'slot1_auto', + replacementSelection: 'manual', + actionTimeoutSeconds: 45, + }); + assert.deepEqual(ruleset.levelPolicy, { + displayMode: 'actual-level-visible', + effectiveFormulaKey: 'soft-cap-after-50-v1', + softCapStartsAt: 50, + effectiveLevelCap: 60, + }); +}); + +test('미지원 generation 조회 실패', () => { + assert.throws(() => getRulesetByGeneration('gen10'), /Unsupported PvP generation: gen10/); +}); + +test('restricted species 포함 여부 판정 성공', () => { + assert.equal(isRestrictedSpecies('gen4', 483), true); + assert.equal(isRestrictedSpecies('gen4', 480), false); +}); + +test('soft-cap-after-50-v1 effective level 계산이 고정된다', () => { + assert.equal(computeEffectiveLevel(1), 1); + assert.equal(computeEffectiveLevel(50), 50); + assert.equal(computeEffectiveLevel(51), 51); + assert.equal(computeEffectiveLevel(60), 52); + assert.equal(computeEffectiveLevel(72), 55); + assert.equal(computeEffectiveLevel(100), 60); +}); + +test('어떤 실제 레벨도 effective level 60을 초과하지 않는다', () => { + for (let actualLevel = 1; actualLevel <= 200; actualLevel += 1) { + assert.ok(computeEffectiveLevel(actualLevel) <= 60, `level ${actualLevel} exceeded cap`); + } +}); diff --git a/test/pvp-session-bootstrap.test.ts b/test/pvp-session-bootstrap.test.ts new file mode 100644 index 00000000..675a0202 --- /dev/null +++ b/test/pvp-session-bootstrap.test.ts @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + PvpSessionBootstrapError, + createPvpSessionBootstrap, + type PvpRoomHttpClient, + type PvpWebSocketCloseEvent, + type PvpWebSocketErrorEvent, + type PvpWebSocketLike, + type PvpWebSocketMessageEvent, +} from '../src/pvp/index.js'; +import type { RoomView } from '../src/server/index.js'; + +const ROOM_VIEW: RoomView = { + room: { + roomId: 'room_000001', + roomCode: 'A7KQ2M', + mode: 'friendly_private', + status: 'awaiting_presence', + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v1', + createdAt: '2026-04-11T07:10:00.000Z', + expiresAt: '2026-04-11T07:25:00.000Z', + }, + you: { + seat: 'host', + partySnapshotId: 'party_000001', + partyValidationStatus: 'accepted', + presence: 'offline', + battleReady: false, + }, + opponent: null, + match: { + freezeStatus: 'waiting_for_opponent', + battleId: null, + battleStartedAt: null, + }, +}; + +class FakeSocket implements PvpWebSocketLike { + onopen: (() => void) | null = null; + + onmessage: ((event: PvpWebSocketMessageEvent) => void) | null = null; + + onclose: ((event: PvpWebSocketCloseEvent) => void) | null = null; + + onerror: ((event: PvpWebSocketErrorEvent) => void) | null = null; + + send(): void {} + + close(): void {} +} + +describe('pvp session bootstrap', () => { + it('createRoomSession creates a room then auto-connects a session client', async () => { + const roomClient: PvpRoomHttpClient = { + createRoom: async (request) => { + assert.deepEqual(request, { + authToken: 'auth-token', + generation: 'gen4', + visibility: 'private_friend', + rulesetKey: 'tkm-friendly-gen4-v1', + }); + return ROOM_VIEW; + }, + joinRoom: async () => { + throw new Error('not used'); + }, + getRoom: async () => { + throw new Error('not used'); + }, + }; + const createdSocketUrls: string[] = []; + const bootstrap = createPvpSessionBootstrap({ + roomClient, + serverUrl: 'https://pvp.example.com', + createSocket(url) { + createdSocketUrls.push(url); + return new FakeSocket(); + }, + }); + + const result = await bootstrap.createRoomSession({ + authToken: 'auth-token', + generation: 'gen4', + visibility: 'private_friend', + rulesetKey: 'tkm-friendly-gen4-v1', + autoConnect: true, + }); + + assert.equal(result.roomId, 'room_000001'); + assert.deepEqual(result.roomView, ROOM_VIEW); + assert.equal(createdSocketUrls.length, 1); + assert.equal( + createdSocketUrls[0], + 'wss://pvp.example.com/ws/pvp?roomId=room_000001&token=auth-token', + ); + assert.equal(result.sessionClient.getState().transportStatus, 'connecting'); + }); + + it('joinRoomSession delegates to the room client and keeps the session idle by default', async () => { + const roomClient: PvpRoomHttpClient = { + createRoom: async () => { + throw new Error('not used'); + }, + joinRoom: async (request) => { + assert.deepEqual(request, { + authToken: 'guest-token', + roomId: 'room_000001', + roomCode: 'A7KQ2M', + generation: 'gen4', + }); + return ROOM_VIEW; + }, + getRoom: async () => { + throw new Error('not used'); + }, + }; + const createdSocketUrls: string[] = []; + const bootstrap = createPvpSessionBootstrap({ + roomClient, + serverUrl: 'https://pvp.example.com', + createSocket(url) { + createdSocketUrls.push(url); + return new FakeSocket(); + }, + }); + + const result = await bootstrap.joinRoomSession({ + authToken: 'guest-token', + roomId: 'room_000001', + roomCode: 'A7KQ2M', + generation: 'gen4', + }); + + assert.equal(result.roomId, 'room_000001'); + assert.equal(result.sessionClient.getState().transportStatus, 'idle'); + assert.deepEqual(createdSocketUrls, []); + }); + + it('can build a session directly from an existing RoomView', () => { + const bootstrap = createPvpSessionBootstrap({ + roomClient: { + createRoom: async () => ROOM_VIEW, + joinRoom: async () => ROOM_VIEW, + getRoom: async () => ROOM_VIEW, + }, + serverUrl: 'https://pvp.example.com', + createSocket() { + return new FakeSocket(); + }, + }); + + const result = bootstrap.createSessionFromRoomView({ + authToken: 'auth-token', + roomView: ROOM_VIEW, + }); + + assert.equal(result.roomId, 'room_000001'); + assert.equal(result.sessionClient.getState().transportStatus, 'idle'); + }); + + it('rejects malformed room views before constructing a session client', () => { + const bootstrap = createPvpSessionBootstrap({ + roomClient: { + createRoom: async () => ROOM_VIEW, + joinRoom: async () => ROOM_VIEW, + getRoom: async () => ROOM_VIEW, + }, + serverUrl: 'https://pvp.example.com', + createSocket() { + return new FakeSocket(); + }, + }); + + assert.throws( + () => bootstrap.createSessionFromRoomView({ + authToken: 'auth-token', + roomView: { + ...ROOM_VIEW, + room: { + ...ROOM_VIEW.room, + roomId: '', + }, + }, + }), + (error: unknown) => { + assert.ok(error instanceof PvpSessionBootstrapError); + assert.equal(error.code, 'PVP_SESSION_BOOTSTRAP_ROOM_ID_INVALID'); + return true; + }, + ); + }); +}); diff --git a/test/pvp-session-client.test.ts b/test/pvp-session-client.test.ts new file mode 100644 index 00000000..1c8334a9 --- /dev/null +++ b/test/pvp-session-client.test.ts @@ -0,0 +1,354 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { ViewerVisibleState } from '../src/server/battle/index.js'; +import { + createPvpSessionClient, + type PvpWebSocketCloseEvent, + type PvpWebSocketErrorEvent, + type PvpWebSocketLike, + type PvpWebSocketMessageEvent, +} from '../src/pvp/index.js'; + +const BASE_VISIBLE_STATE: ViewerVisibleState = { + self: { + active: { + slot: 1, + speciesId: '001', + nickname: 'Bulba', + levelActual: 55, + levelEffective: 52, + hp: 120, + hpMax: 120, + status: null, + fainted: false, + moves: [ + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + { slot: 2, id: 'growl', disabled: false, currentPp: 40 }, + ], + }, + bench: [ + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: false }, + ], + }, + opponent: { + active: { + slot: 1, + speciesId: '133', + nickname: 'Eevee', + levelActual: 54, + levelEffective: 52, + hp: 110, + hpMax: 110, + status: null, + fainted: false, + }, + benchCount: 2, + }, +}; + +function cloneVisibleState(): ViewerVisibleState { + return { + self: { + active: { ...BASE_VISIBLE_STATE.self.active }, + bench: BASE_VISIBLE_STATE.self.bench.map((entry) => ({ ...entry })), + }, + opponent: { + active: { ...BASE_VISIBLE_STATE.opponent.active }, + benchCount: BASE_VISIBLE_STATE.opponent.benchCount, + }, + }; +} + +function makeSnapshot(seq = 1) { + return { + type: 'room.snapshot', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T12:00:0${seq}.000Z`, + payload: { + roomStatus: 'in_progress', + battleStatus: 'awaiting_actions', + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v1', + yourSeat: 'host', + turn: 3, + visibleState: cloneVisibleState(), + pendingRequest: { + kind: 'choose_move_or_switch', + deadlineMs: 30_000, + commandSubmitted: false, + }, + }, + } as const; +} + +function makeActionRequest(seq = 2) { + return { + type: 'battle.request_action', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T12:00:0${seq}.000Z`, + payload: { + turn: 3, + phase: 'awaiting_actions', + requestId: 'req-turn-3', + deadlineMs: 25_000, + request: { + kind: 'choose_move_or_switch', + activePokemon: { ...cloneVisibleState().self.active }, + availableMoves: [ + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + { slot: 2, id: 'growl', disabled: false, currentPp: 40 }, + ], + availableSwitches: [ + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: false }, + ], + }, + }, + } as const; +} + +class FakeSocket implements PvpWebSocketLike { + onopen: (() => void) | null = null; + + onmessage: ((event: PvpWebSocketMessageEvent) => void) | null = null; + + onclose: ((event: PvpWebSocketCloseEvent) => void) | null = null; + + onerror: ((event: PvpWebSocketErrorEvent) => void) | null = null; + + readonly sent: string[] = []; + + readonly closes: Array<{ code?: number; reason?: string }> = []; + + send(data: string): void { + this.sent.push(data); + } + + close(code?: number, reason?: string): void { + this.closes.push({ code, reason }); + } + + emitOpen(): void { + this.onopen?.(); + } + + emitMessage(data: string): void { + this.onmessage?.({ data }); + } + + emitClose(event: PvpWebSocketCloseEvent = {}): void { + this.onclose?.(event); + } + + emitError(event: PvpWebSocketErrorEvent = {}): void { + this.onerror?.(event); + } +} + +function createTimeHarness(start = '2026-04-11T12:00:00.000Z') { + let current = new Date(start).getTime(); + let nextId = 1; + const timers = new Map void }>(); + + const scheduler = { + setTimeout(callback: () => void, delayMs: number): number { + const id = nextId++; + timers.set(id, { + runAt: current + delayMs, + callback, + }); + return id; + }, + clearTimeout(handle: unknown): void { + if (typeof handle === 'number') { + timers.delete(handle); + } + }, + }; + + function advance(ms: number): void { + const target = current + ms; + + while (true) { + let nextTimer: { id: number; runAt: number; callback: () => void } | null = null; + for (const [id, timer] of timers.entries()) { + if (timer.runAt > target) { + continue; + } + + if (!nextTimer || timer.runAt < nextTimer.runAt || (timer.runAt === nextTimer.runAt && id < nextTimer.id)) { + nextTimer = { id, runAt: timer.runAt, callback: timer.callback }; + } + } + + if (!nextTimer) { + break; + } + + current = nextTimer.runAt; + timers.delete(nextTimer.id); + nextTimer.callback(); + } + + current = target; + } + + return { + now: () => new Date(current), + scheduler, + advance, + }; +} + +describe('pvp session client', () => { + it('exposes derived session and protocol state after a snapshot', () => { + const time = createTimeHarness(); + const sockets: FakeSocket[] = []; + const client = createPvpSessionClient({ + serverUrl: 'https://pvp.example.com', + roomId: 'room_000001', + token: 'auth-token', + now: time.now, + scheduler: time.scheduler, + createSocket() { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + }); + + client.connect(); + sockets[0].emitOpen(); + sockets[0].emitMessage(JSON.stringify(makeSnapshot())); + + const state = client.getState(); + assert.equal(state.transportStatus, 'connected'); + assert.equal(state.session.roomId, 'room_000001'); + assert.equal(state.session.battleId, 'battle_000001'); + assert.equal(state.protocol.session.roomId, 'room_000001'); + assert.equal(state.protocol.lastTransportMessageType, 'room.snapshot'); + assert.equal(state.hasPendingRequest, true); + assert.equal(state.activeRequestKind, 'choose_move_or_switch'); + assert.equal(state.canSendCommand, true); + assert.equal(state.reconnect.scheduled, false); + }); + + it('locks command sending in derived state after sending a battle command', () => { + const time = createTimeHarness(); + const socket = new FakeSocket(); + const client = createPvpSessionClient({ + serverUrl: 'wss://pvp.example.com/ws/pvp', + roomId: 'room_000001', + token: 'auth-token', + now: time.now, + scheduler: time.scheduler, + createSocket() { + return socket; + }, + }); + + client.connect(); + socket.emitOpen(); + socket.emitMessage(JSON.stringify(makeSnapshot())); + socket.emitMessage(JSON.stringify(makeActionRequest())); + time.advance(2_000); + + assert.equal(client.getState().canSendCommand, true); + + const result = client.sendBattleCommand({ + clientCommandId: 'cmd-1', + sentAt: time.now().toISOString(), + command: { + type: 'choose_move', + moveSlot: 1, + }, + }); + + assert.equal(socket.sent.length, 1); + assert.equal(result.state.canSendCommand, false); + assert.equal(result.state.hasPendingRequest, true); + assert.equal(result.state.session.pendingCommand?.clientCommandId, 'cmd-1'); + assert.equal(result.state.protocol.session.pendingCommand?.clientCommandId, 'cmd-1'); + }); + + it('surfaces reconnect scheduling metadata after an unexpected close', () => { + const time = createTimeHarness(); + const sockets: FakeSocket[] = []; + const client = createPvpSessionClient({ + serverUrl: 'https://pvp.example.com', + roomId: 'room_000001', + token: 'auth-token', + now: time.now, + scheduler: time.scheduler, + baseDelayMs: 1_000, + maxDelayMs: 8_000, + multiplier: 2, + createSocket() { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + }); + + client.connect(); + sockets[0].emitOpen(); + time.advance(500); + sockets[0].emitClose({ code: 4002, reason: 'PVP_WS_HEARTBEAT_TIMEOUT', wasClean: false }); + + const state = client.getState(); + assert.equal(state.transportStatus, 'closed'); + assert.equal(state.reconnect.autoReconnectEnabled, true); + assert.equal(state.reconnect.scheduled, true); + assert.equal(state.reconnect.attempt, 1); + assert.equal(state.reconnect.delay, 1_000); + assert.equal(state.reconnect.nextReconnectAt, '2026-04-11T12:00:01.500Z'); + assert.equal(state.reconnect.lastTrigger, 'transport_close'); + }); + + it('returns to connected after a successful reconnect', () => { + const time = createTimeHarness(); + const sockets: FakeSocket[] = []; + const client = createPvpSessionClient({ + serverUrl: 'https://pvp.example.com', + roomId: 'room_000001', + token: 'auth-token', + now: time.now, + scheduler: time.scheduler, + baseDelayMs: 1_000, + maxDelayMs: 8_000, + multiplier: 2, + createSocket() { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + }); + + client.connect(); + sockets[0].emitOpen(); + sockets[0].emitMessage(JSON.stringify(makeSnapshot())); + time.advance(500); + sockets[0].emitClose({ code: 4002, reason: 'PVP_WS_HEARTBEAT_TIMEOUT', wasClean: false }); + + time.advance(1_000); + assert.equal(sockets.length, 2); + assert.equal(client.getState().transportStatus, 'reconnecting'); + + sockets[1].emitOpen(); + + const state = client.getState(); + assert.equal(state.transportStatus, 'connected'); + assert.equal(state.reconnect.scheduled, false); + assert.equal(state.reconnect.attempt, 0); + assert.equal(state.reconnect.delay, null); + assert.equal(state.reconnect.nextReconnectAt, null); + assert.equal(state.reconnect.lastTrigger, null); + assert.equal(state.session.roomId, 'room_000001'); + }); +}); diff --git a/test/pvp-session-screen-renderer.test.ts b/test/pvp-session-screen-renderer.test.ts new file mode 100644 index 00000000..47843f31 --- /dev/null +++ b/test/pvp-session-screen-renderer.test.ts @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createPvpClientState, + createPvpSessionState, + renderPvpSessionClientScreen, + renderPvpSessionScreen, + type PvpSessionClientState, + type PvpSessionState, + type PvpTurnResolvedPayloadLike, +} from '../src/pvp/index.js'; + +function createBaseClientState(): PvpSessionClientState { + const session = createPvpSessionState(); + const protocol = createPvpClientState(); + protocol.session = session; + + return { + transportStatus: 'idle', + session, + protocol, + reconnect: { + autoReconnectEnabled: true, + attempt: 0, + scheduled: false, + delay: null, + nextReconnectAt: null, + lastTrigger: null, + }, + canSendCommand: false, + hasPendingRequest: false, + activeRequestKind: null, + }; +} + +function buildActionRequest(overrides: Partial = {}) { + return { + kind: 'choose_move_or_switch' as const, + phase: 'awaiting_actions' as const, + turn: 18, + deadlineMs: 30_000, + commandSubmitted: false, + requestId: 'req-18', + activePokemon: { + slot: 1, + speciesId: '025', + nickname: 'Pika', + levelActual: 66, + levelEffective: 60, + hp: 88, + hpMax: 120, + status: null, + fainted: false, + }, + availableMoves: [ + { slot: 1, id: 'thunderbolt', disabled: false, currentPp: 10 }, + { slot: 2, id: 'quick_attack', disabled: false, currentPp: 30 }, + ], + availableSwitches: [ + { slot: 2, speciesId: '133', nickname: 'Eevee', fainted: false }, + ], + ...overrides, + }; +} + +function createVisibleState() { + return { + self: { + active: { + slot: 1, + speciesId: '025', + nickname: 'Pika', + levelActual: 66, + levelEffective: 60, + hp: 88, + hpMax: 120, + status: null, + fainted: false, + }, + bench: [ + { slot: 2, speciesId: '133', nickname: 'Eevee', fainted: false }, + { slot: 3, speciesId: '143', nickname: 'Snorlax', fainted: true }, + ], + }, + opponent: { + active: { + slot: 1, + speciesId: '006', + nickname: 'Zard', + levelActual: 72, + levelEffective: 60, + hp: 30, + hpMax: 150, + status: 'burn', + fainted: false, + }, + benchCount: 2, + }, + }; +} + +describe('pvp session screen renderer', () => { + it('renders null/empty screens into a deterministic placeholder layout', () => { + const direct = renderPvpSessionScreen(null); + const fromState = renderPvpSessionClientScreen(null); + + assert.equal(direct, fromState); + assert.equal( + direct, + [ + '=== PvP Session Screen ===', + '상태: 세션 없음', + '', + '[transport]', + '- 상태: 세션 없음', + '- 상세: 세션 스냅샷이 아직 없습니다.', + '', + '[session]', + '- 요약: 세션 스냅샷이 아직 없습니다.', + '', + '[action-request]', + '- 대기 중인 행동 요청이 없습니다.', + '', + '[command-status]', + '- 제출 상태를 표시할 세션이 없습니다.', + '', + '[turn-result]', + '- 표시할 턴 결과가 없습니다.', + ].join('\n'), + ); + }); + + it('renders awaiting_input screens with summaries, menu entries, command state, and turn result blocks', () => { + const state = createBaseClientState(); + state.transportStatus = 'connected'; + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + state.session.roomId = 'room-18'; + state.session.battleId = 'battle-18'; + state.session.roomStatus = 'in_battle'; + state.session.battleStatus = 'awaiting_actions'; + state.session.generation = 'gen3'; + state.session.rulesetKey = 'tkm-gen3-friendly'; + state.session.yourSeat = 'guest'; + state.session.turn = 18; + state.session.lastEventType = 'battle.turn_resolved'; + state.session.lastEventAt = '2026-04-11T16:00:00.000Z'; + state.session.pendingRequest = buildActionRequest(); + state.session.visibleState = createVisibleState(); + state.session.lastResolvedTurn = 17; + + const payload: PvpTurnResolvedPayloadLike = { + turn: 17, + nextPhase: 'awaiting_actions', + postTurnVisibleState: createVisibleState(), + events: [ + { + eventType: 'move_used', + actor: 'self', + actorSlot: 1, + actorSpeciesId: '025', + moveSlot: 1, + moveId: 'thunderbolt', + }, + ], + }; + + const rendered = renderPvpSessionClientScreen(state, payload); + + assert.match(rendered, /^=== PvP Session Screen ===/); + assert.match(rendered, /상태: 행동 선택 가능/); + assert.match(rendered, /- 상세: 기술을 쓰거나 교체할 포켓몬을 선택하세요\./); + assert.match(rendered, /\[transport\]\n- 상태: 실시간 연결됨/); + assert.match(rendered, /\[session\]\n- 요약: gen3 · 룰 tkm-gen3-friendly · 18턴 · guest/); + assert.match(rendered, /- 마지막 이벤트: 마지막 이벤트 battle.turn_resolved · 2026-04-11T16:00:00.000Z/); + assert.match(rendered, /\[action-request\]\n- 제목: Pika \(025\) 행동 선택/); + assert.match(rendered, / \* \[가능\] 1\. thunderbolt \| token=1 \| PP 10/); + assert.match(rendered, / \* \[가능\] Eevee \(133\) \| token=switch:2 \| 슬롯 2/); + assert.match(rendered, /\[command-status\]\n- 상태: 명령 선택 가능/); + assert.match(rendered, /\[turn-result\]\n- 제목: 턴 17 결과/); + assert.match(rendered, / \* 기술 사용: 내 025 \(슬롯 1\)이\(가\) thunderbolt 사용/); + }); + + it('renders reconnecting screens with consistent empty placeholders', () => { + const state = createBaseClientState(); + state.transportStatus = 'reconnecting'; + state.reconnect = { + autoReconnectEnabled: true, + attempt: 2, + scheduled: true, + delay: 2000, + nextReconnectAt: '2026-04-11T16:10:02.000Z', + lastTrigger: 'transport_close', + }; + + const rendered = renderPvpSessionClientScreen(state); + + const lines = rendered.split('\n'); + assert.equal(lines[0], '=== PvP Session Screen ==='); + assert.equal(lines[1], '상태: 재접속 중'); + assert.match(rendered, /- 상세: 2회차 재접속 예약 · 2026-04-11T16:10:02.000Z/); + assert.match(rendered, /\[action-request\]\n- 아직 표시할 배틀 요청이나 결과가 없습니다\./); + assert.match(rendered, /\[turn-result\]\n- 아직 표시할 배틀 요청이나 결과가 없습니다\./); + }); + + it('renders terminal sessions with final summaries and logs when available', () => { + const state = createBaseClientState(); + state.transportStatus = 'closed'; + state.session.roomId = 'room-final'; + state.session.battleId = 'battle-final'; + state.session.battleStatus = 'finished'; + state.session.turn = 19; + state.session.visibleState = createVisibleState(); + state.session.lastResolvedTurn = 19; + state.session.terminalResult = { + result: 'win', + reason: 'forfeit', + finalVisibleState: { + self: { remainingCount: 3 }, + opponent: { remainingCount: 0 }, + }, + }; + + const rendered = renderPvpSessionClientScreen(state); + + assert.match(rendered, /상태: 전투 종료/); + assert.match(rendered, /- 상세: 승리 · 종료 사유 forfeit/); + assert.match(rendered, /\[transport\]\n- 상태: 연결 종료/); + assert.match(rendered, /\[turn-result\]\n- 제목: 턴 19 결과/); + assert.match(rendered, /- 종료: 승리 · 종료 사유 forfeit/); + }); +}); diff --git a/test/pvp-session-screen-view.test.ts b/test/pvp-session-screen-view.test.ts new file mode 100644 index 00000000..eb19898e --- /dev/null +++ b/test/pvp-session-screen-view.test.ts @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createPvpClientState, + createPvpSessionScreenView, + createPvpSessionState, + type PvpSessionClientState, + type PvpSessionState, + type PvpTurnResolvedPayloadLike, +} from '../src/pvp/index.js'; + +function createBaseClientState(): PvpSessionClientState { + const session = createPvpSessionState(); + const protocol = createPvpClientState(); + protocol.session = session; + + return { + transportStatus: 'idle', + session, + protocol, + reconnect: { + autoReconnectEnabled: true, + attempt: 0, + scheduled: false, + delay: null, + nextReconnectAt: null, + lastTrigger: null, + }, + canSendCommand: false, + hasPendingRequest: false, + activeRequestKind: null, + }; +} + +function buildActionRequest(overrides: Partial = {}) { + return { + kind: 'choose_move_or_switch' as const, + phase: 'awaiting_actions' as const, + turn: 18, + deadlineMs: 30_000, + commandSubmitted: false, + requestId: 'req-18', + activePokemon: { + slot: 1, + speciesId: '025', + nickname: 'Pika', + levelActual: 66, + levelEffective: 60, + hp: 88, + hpMax: 120, + status: null, + fainted: false, + }, + availableMoves: [ + { slot: 1, id: 'thunderbolt', disabled: false, currentPp: 10 }, + { slot: 2, id: 'quick_attack', disabled: false, currentPp: 30 }, + ], + availableSwitches: [ + { slot: 2, speciesId: '133', nickname: 'Eevee', fainted: false }, + ], + ...overrides, + }; +} + +function createVisibleState() { + return { + self: { + active: { + slot: 1, + speciesId: '025', + nickname: 'Pika', + levelActual: 66, + levelEffective: 60, + hp: 88, + hpMax: 120, + status: null, + fainted: false, + }, + bench: [ + { slot: 2, speciesId: '133', nickname: 'Eevee', fainted: false }, + { slot: 3, speciesId: '143', nickname: 'Snorlax', fainted: true }, + ], + }, + opponent: { + active: { + slot: 1, + speciesId: '006', + nickname: 'Zard', + levelActual: 72, + levelEffective: 60, + hp: 30, + hpMax: 150, + status: 'burn', + fainted: false, + }, + benchCount: 2, + }, + }; +} + +describe('pvp session screen view', () => { + it('returns null for null state', () => { + assert.equal(createPvpSessionScreenView(null), null); + }); + + it('summarizes transport, session meta, request/command/turn subviews into one deterministic screen model', () => { + const state = createBaseClientState(); + state.transportStatus = 'connected'; + state.canSendCommand = false; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + state.reconnect = { + autoReconnectEnabled: true, + attempt: 0, + scheduled: false, + delay: null, + nextReconnectAt: null, + lastTrigger: null, + }; + state.session.roomId = 'room-17'; + state.session.battleId = 'battle-17'; + state.session.roomStatus = 'in_battle'; + state.session.battleStatus = 'awaiting_actions'; + state.session.generation = 'gen2'; + state.session.rulesetKey = 'tkm-gen2-friendly'; + state.session.yourSeat = 'host'; + state.session.turn = 18; + state.session.lastEventType = 'battle.command_accepted'; + state.session.lastEventAt = '2026-04-11T15:00:00.000Z'; + state.session.pendingRequest = buildActionRequest({ commandSubmitted: true }); + state.session.pendingCommand = { + clientCommandId: 'cmd-18', + turn: 18, + phase: 'awaiting_actions', + command: { type: 'choose_move', moveSlot: 1 }, + seq: 9, + sentAt: '2026-04-11T15:00:00.000Z', + status: 'accepted', + lockedIn: true, + }; + state.session.visibleState = createVisibleState(); + state.session.lastResolvedTurn = 17; + + const payload: PvpTurnResolvedPayloadLike = { + turn: 17, + nextPhase: 'awaiting_actions', + postTurnVisibleState: createVisibleState(), + events: [ + { + eventType: 'move_used', + actor: 'self', + actorSlot: 1, + actorSpeciesId: '025', + moveSlot: 1, + moveId: 'thunderbolt', + }, + ], + }; + + const view = createPvpSessionScreenView(state, payload); + + assert.ok(view); + assert.equal(view.source, 'session'); + assert.equal(view.status, 'command_locked'); + assert.equal(view.statusLabel, '명령 제출 완료'); + assert.equal(view.emptyStateLabel, null); + assert.equal(view.terminal, false); + assert.equal(view.transport.summaryLabel, '실시간 연결됨'); + assert.equal(view.transport.detailLabel, '자동 재접속 대기 없음'); + assert.equal(view.transport.reconnectLabel, '자동 재접속 켜짐'); + assert.equal(view.session.summaryLabel, 'gen2 · 룰 tkm-gen2-friendly · 18턴 · host'); + assert.equal(view.session.detailLabel, 'room room-17 · battle battle-17 · room in_battle · battle awaiting_actions'); + assert.equal(view.session.lastEventLabel, '마지막 이벤트 battle.command_accepted · 2026-04-11T15:00:00.000Z'); + assert.equal(view.actionRequest?.title, 'Pika (025) 행동 선택'); + assert.equal(view.commandStatus?.statusLabel, '명령 접수 완료'); + assert.equal(view.turnResult?.turn, 17); + assert.equal(view.turnResult?.source, 'payload'); + assert.equal(view.hasPendingRequest, true); + assert.equal(view.hasTurnResult, true); + }); + + it('renders reconnecting empty states without requiring a pending request or turn result', () => { + const state = createBaseClientState(); + state.transportStatus = 'reconnecting'; + state.reconnect = { + autoReconnectEnabled: true, + attempt: 2, + scheduled: true, + delay: 2000, + nextReconnectAt: '2026-04-11T15:10:02.000Z', + lastTrigger: 'transport_close', + }; + + const view = createPvpSessionScreenView(state); + + assert.ok(view); + assert.equal(view.status, 'reconnecting'); + assert.equal(view.statusLabel, '재접속 중'); + assert.equal(view.emptyStateLabel, '아직 표시할 배틀 요청이나 결과가 없습니다.'); + assert.equal(view.transport.summaryLabel, '재접속 시도 중'); + assert.equal(view.transport.detailLabel, '2회차 재접속 예약 · 2026-04-11T15:10:02.000Z'); + assert.equal(view.transport.reconnectLabel, '자동 재접속 켜짐'); + assert.equal(view.session.summaryLabel, '세션 메타 대기 중'); + assert.equal(view.actionRequest, null); + assert.equal(view.commandStatus?.statusLabel, '제출 대기 없음'); + assert.equal(view.turnResult, null); + assert.equal(view.hasRenderableContent, false); + }); + + it('marks terminal sessions and can fall back to session-only turn summaries', () => { + const state = createBaseClientState(); + state.transportStatus = 'closed'; + state.session.roomId = 'room-final'; + state.session.battleId = 'battle-final'; + state.session.battleStatus = 'finished'; + state.session.turn = 19; + state.session.visibleState = createVisibleState(); + state.session.lastResolvedTurn = 19; + state.session.terminalResult = { + result: 'win', + reason: 'forfeit', + finalVisibleState: { + self: { remainingCount: 3 }, + opponent: { remainingCount: 0 }, + }, + }; + + const view = createPvpSessionScreenView(state); + + assert.ok(view); + assert.equal(view.status, 'terminal'); + assert.equal(view.statusLabel, '전투 종료'); + assert.equal(view.terminal, true); + assert.equal(view.transport.summaryLabel, '연결 종료'); + assert.equal(view.turnResult?.source, 'session'); + assert.equal(view.turnResult?.summary.terminalResultLabel, '승리 · 종료 사유 forfeit'); + }); +}); diff --git a/test/pvp-session-store.test.ts b/test/pvp-session-store.test.ts new file mode 100644 index 00000000..dc7fd835 --- /dev/null +++ b/test/pvp-session-store.test.ts @@ -0,0 +1,411 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { + BattleCommandRejectedPayload, + BattleServerEventEnvelope, + ViewerVisibleState, +} from '../src/server/battle/index.js'; +import { + applyPvpServerEvent, + createBattleCommandEnvelope, + createPvpSessionState, + hasPendingAction, + isCommandLocked, +} from '../src/pvp/index.js'; + +const BASE_VISIBLE_STATE: ViewerVisibleState = { + self: { + active: { + slot: 1, + speciesId: '001', + nickname: 'Bulba', + levelActual: 55, + levelEffective: 52, + hp: 120, + hpMax: 120, + status: null, + fainted: false, + moves: [ + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + { slot: 2, id: 'growl', disabled: false, currentPp: 40 }, + ], + }, + bench: [ + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: false }, + ], + }, + opponent: { + active: { + slot: 1, + speciesId: '133', + nickname: 'Eevee', + levelActual: 54, + levelEffective: 52, + hp: 110, + hpMax: 110, + status: null, + fainted: false, + }, + benchCount: 2, + }, +}; + +function cloneVisibleState(overrides?: Partial): ViewerVisibleState { + return { + self: { + active: { ...BASE_VISIBLE_STATE.self.active }, + bench: BASE_VISIBLE_STATE.self.bench.map((entry) => ({ ...entry })), + ...(overrides?.self ?? {}), + }, + opponent: { + active: { ...BASE_VISIBLE_STATE.opponent.active }, + benchCount: BASE_VISIBLE_STATE.opponent.benchCount, + ...(overrides?.opponent ?? {}), + }, + }; +} + +function makeSnapshot(seq = 1): BattleServerEventEnvelope { + return { + type: 'room.snapshot', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + roomStatus: 'in_progress', + battleStatus: 'awaiting_actions', + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v1', + yourSeat: 'host', + turn: 3, + visibleState: cloneVisibleState(), + pendingRequest: { + kind: 'choose_move_or_switch', + deadlineMs: 30_000, + commandSubmitted: false, + }, + }, + }; +} + +function makeActionRequest(seq = 2): BattleServerEventEnvelope { + return { + type: 'battle.request_action', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + turn: 3, + phase: 'awaiting_actions', + requestId: 'req-turn-3', + deadlineMs: 25_000, + request: { + kind: 'choose_move_or_switch', + activePokemon: { ...cloneVisibleState().self.active }, + availableMoves: [ + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + { slot: 2, id: 'growl', disabled: false, currentPp: 40 }, + ], + availableSwitches: [ + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: false }, + ], + }, + }, + }; +} + +function makeAccepted(clientCommandId = 'cmd-1', seq = 3): BattleServerEventEnvelope { + return { + type: 'battle.command_accepted', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + clientCommandId, + turn: 3, + phase: 'awaiting_actions', + lockedIn: true, + }, + }; +} + +function makeRejected( + payload: Partial = {}, + seq = 4, +): BattleServerEventEnvelope { + return { + type: 'battle.command_rejected', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + clientCommandId: payload.clientCommandId ?? 'cmd-1', + code: payload.code ?? 'PVP_COMMAND_PHASE_MISMATCH', + message: payload.message ?? 'phase mismatch', + retryable: payload.retryable ?? true, + }, + }; +} + +function makeTurnResolved(seq = 5): BattleServerEventEnvelope { + return { + type: 'battle.turn_resolved', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + turn: 3, + events: [ + { + eventType: 'move_used', + actor: 'self', + actorSlot: 1, + actorSpeciesId: '001', + moveSlot: 1, + moveId: 'tackle', + }, + ], + postTurnVisibleState: cloneVisibleState({ + self: { + active: { + ...BASE_VISIBLE_STATE.self.active, + hp: 95, + hpMax: 120, + }, + }, + }), + nextPhase: 'awaiting_actions', + }, + }; +} + +function makeForceReplacement(seq = 6): BattleServerEventEnvelope { + return { + type: 'battle.force_replacement', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + turn: 4, + phase: 'awaiting_replacement', + requestId: 'replace-turn-4', + deadlineMs: 20_000, + faintedSlot: 1, + availableReplacements: [ + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: false }, + ], + }, + }; +} + +function makeEnded(seq = 7): BattleServerEventEnvelope { + return { + type: 'battle.ended', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + result: 'win', + reason: 'all_opponent_pokemon_fainted', + finalVisibleState: { + self: { remainingCount: 2 }, + opponent: { remainingCount: 0 }, + }, + }, + }; +} + +describe('pvp session store', () => { + it('snapshot bootstraps state', () => { + const state = applyPvpServerEvent(createPvpSessionState(), makeSnapshot()); + + assert.equal(state.roomId, 'room_000001'); + assert.equal(state.battleId, 'battle_000001'); + assert.equal(state.battleStatus, 'awaiting_actions'); + assert.equal(state.turn, 3); + assert.equal(state.pendingRequest?.kind, 'choose_move_or_switch'); + assert.equal(state.visibleState?.self.active.speciesId, '001'); + assert.equal(hasPendingAction(state), true); + assert.equal(isCommandLocked(state), false); + }); + + it('request_action sets actionable request', () => { + const snapshot = applyPvpServerEvent(createPvpSessionState(), makeSnapshot()); + const state = applyPvpServerEvent(snapshot, makeActionRequest()); + + assert.equal(state.pendingRequest?.kind, 'choose_move_or_switch'); + assert.equal(state.pendingRequest?.requestId, 'req-turn-3'); + assert.equal(state.pendingRequest?.availableMoves?.length, 2); + assert.equal(state.pendingRequest?.availableSwitches?.length, 2); + assert.equal(hasPendingAction(state), true); + assert.equal(isCommandLocked(state), false); + }); + + it('accepted command locks input', () => { + const requested = applyPvpServerEvent( + applyPvpServerEvent(createPvpSessionState(), makeSnapshot()), + makeActionRequest(), + ); + const submitted = createBattleCommandEnvelope(requested, { + clientCommandId: 'cmd-1', + sentAt: '2026-04-11T10:01:00.000Z', + command: { type: 'choose_move', moveSlot: 1 }, + }); + const locked = applyPvpServerEvent(submitted.state, makeAccepted('cmd-1')); + + assert.equal(submitted.envelope.payload.phase, 'awaiting_actions'); + assert.equal(locked.pendingRequest?.commandSubmitted, true); + assert.equal(locked.pendingCommand?.status, 'accepted'); + assert.equal(isCommandLocked(locked), true); + }); + + it('retryable rejection unlocks input', () => { + const requested = applyPvpServerEvent( + applyPvpServerEvent(createPvpSessionState(), makeSnapshot()), + makeActionRequest(), + ); + const submitted = createBattleCommandEnvelope(requested, { + clientCommandId: 'cmd-1', + sentAt: '2026-04-11T10:01:00.000Z', + command: { type: 'choose_move', moveSlot: 1 }, + }); + const rejected = applyPvpServerEvent( + submitted.state, + makeRejected({ clientCommandId: 'cmd-1', retryable: true }), + ); + + assert.equal(rejected.pendingRequest?.commandSubmitted, false); + assert.equal(rejected.pendingCommand, null); + assert.equal(rejected.lastRejectedCommand?.retryable, true); + assert.equal(hasPendingAction(rejected), true); + assert.equal(isCommandLocked(rejected), false); + }); + + it('turn_resolved clears transient state and updates visibleState', () => { + const requested = applyPvpServerEvent( + applyPvpServerEvent(createPvpSessionState(), makeSnapshot()), + makeActionRequest(), + ); + const submitted = createBattleCommandEnvelope(requested, { + clientCommandId: 'cmd-1', + sentAt: '2026-04-11T10:01:00.000Z', + command: { type: 'choose_move', moveSlot: 1 }, + }); + const resolved = applyPvpServerEvent(submitted.state, makeTurnResolved()); + + assert.equal(resolved.pendingRequest, null); + assert.equal(resolved.pendingCommand, null); + assert.equal(resolved.lastResolvedTurn, 3); + assert.equal(resolved.visibleState?.self.active.hp, 95); + assert.equal(isCommandLocked(resolved), false); + }); + + it('force_replacement creates replacement request', () => { + const state = applyPvpServerEvent(createPvpSessionState(), makeForceReplacement()); + + assert.equal(state.battleStatus, 'awaiting_replacement'); + assert.equal(state.pendingRequest?.kind, 'choose_replacement'); + assert.equal(state.pendingRequest?.faintedSlot, 1); + assert.equal(state.pendingRequest?.availableReplacements?.length, 2); + assert.equal(hasPendingAction(state), true); + }); + + it('ended clears pending request and marks terminal state', () => { + const requested = applyPvpServerEvent( + applyPvpServerEvent(createPvpSessionState(), makeSnapshot()), + makeActionRequest(), + ); + const ended = applyPvpServerEvent(requested, makeEnded()); + + assert.equal(ended.battleStatus, 'finished'); + assert.equal(ended.pendingRequest, null); + assert.equal(ended.pendingCommand, null); + assert.equal(ended.terminalResult?.result, 'win'); + assert.equal(hasPendingAction(ended), false); + }); + + it('authoritative snapshot clears stale local pending submission on resync', () => { + const requested = applyPvpServerEvent( + applyPvpServerEvent(createPvpSessionState(), makeSnapshot()), + makeActionRequest(), + ); + const submitted = createBattleCommandEnvelope(requested, { + clientCommandId: 'cmd-stale', + sentAt: '2026-04-11T10:01:00.000Z', + command: { type: 'choose_move', moveSlot: 1 }, + }); + const resynced = applyPvpServerEvent( + submitted.state, + { + ...makeSnapshot(50), + payload: { + ...makeSnapshot(50).payload, + pendingRequest: { + kind: 'choose_move_or_switch', + deadlineMs: 10_000, + commandSubmitted: false, + }, + }, + }, + ); + + assert.ok(submitted.state.pendingCommand); + assert.equal(resynced.pendingCommand, null); + assert.equal(resynced.pendingRequest?.commandSubmitted, false); + assert.equal(resynced.resyncCount, 1); + assert.equal(isCommandLocked(resynced), false); + }); + + it('createBattleCommandEnvelope builds valid envelope from current pending request and rejects impossible cases', () => { + const requested = applyPvpServerEvent( + applyPvpServerEvent(createPvpSessionState(), makeSnapshot()), + makeActionRequest(), + ); + const created = createBattleCommandEnvelope(requested, { + clientCommandId: 'cmd-switch', + sentAt: '2026-04-11T10:02:00.000Z', + command: { type: 'choose_switch', targetSlot: 2 }, + }); + + assert.equal(created.envelope.type, 'battle.command'); + assert.equal(created.envelope.roomId, 'room_000001'); + assert.equal(created.envelope.battleId, 'battle_000001'); + assert.equal(created.envelope.seq, 1); + assert.equal(created.envelope.payload.turn, 3); + assert.equal(created.envelope.payload.phase, 'awaiting_actions'); + assert.deepEqual(created.envelope.payload.command, { type: 'choose_switch', targetSlot: 2 }); + assert.equal(created.state.nextClientSeq, 2); + assert.equal(isCommandLocked(created.state), true); + + assert.throws( + () => + createBattleCommandEnvelope(requested, { + clientCommandId: 'cmd-bad', + sentAt: '2026-04-11T10:02:10.000Z', + command: { type: 'choose_replacement', targetSlot: 2 }, + }), + /current pending request/i, + ); + + assert.throws( + () => + createBattleCommandEnvelope(created.state, { + clientCommandId: 'cmd-duplicate', + sentAt: '2026-04-11T10:02:20.000Z', + command: { type: 'choose_move', moveSlot: 1 }, + }), + /locked/i, + ); + }); +}); diff --git a/test/pvp-session-terminal-cli.test.ts b/test/pvp-session-terminal-cli.test.ts new file mode 100644 index 00000000..754e8c8f --- /dev/null +++ b/test/pvp-session-terminal-cli.test.ts @@ -0,0 +1,296 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createPvpSessionTerminalCli, + createPvpSessionTerminalSnapshot, + type PvpSessionTerminalCliInputSource, + type PvpSessionTerminalCliScreenOutput, + type PvpSessionTerminalCliState, + type PvpSessionTerminalRunnerLike, + type PvpSessionTerminalRunnerState, + type PvpSessionTerminalRunnerStateListener, + type PvpSessionTerminalSubmitResult, +} from '../src/pvp/index.js'; + +function createRunnerState(overrides: Partial = {}): PvpSessionTerminalRunnerState { + const snapshot = createPvpSessionTerminalSnapshot(null); + snapshot.screen = overrides.screen ?? 'screen:idle'; + snapshot.availableInputTokens = [...(overrides.availableInputTokens ?? [])]; + + return { + running: overrides.running ?? false, + revision: overrides.revision ?? 0, + snapshot, + screen: overrides.screen ?? snapshot.screen, + availableInputTokens: [...(overrides.availableInputTokens ?? snapshot.availableInputTokens)], + lastSubmitResult: overrides.lastSubmitResult ?? null, + }; +} + +class FakeInputSource implements PvpSessionTerminalCliInputSource { + private readonly listeners = new Set<(token: string) => void>(); + + subscribeCount = 0; + + unsubscribeCount = 0; + + subscribe(listener: (token: string) => void): () => void { + this.subscribeCount += 1; + this.listeners.add(listener); + + return () => { + if (this.listeners.delete(listener)) { + this.unsubscribeCount += 1; + } + }; + } + + emit(token: string): void { + for (const listener of this.listeners) { + listener(token); + } + } +} + +class FakeScreenOutput implements PvpSessionTerminalCliScreenOutput { + readonly repaints: Array<{ screen: string; state: PvpSessionTerminalRunnerState }> = []; + + repaint(screen: string, state: PvpSessionTerminalRunnerState): void { + this.repaints.push({ + screen, + state: structuredClone(state), + }); + } +} + +class FakeRunner implements PvpSessionTerminalRunnerLike { + private state: PvpSessionTerminalRunnerState; + + private readonly listeners = new Set(); + + readonly events: string[] = []; + + readonly submitTokens: string[] = []; + + subscribeCount = 0; + + unsubscribeCount = 0; + + startCalls = 0; + + stopCalls = 0; + + constructor(initialState: PvpSessionTerminalRunnerState, private readonly startedState: PvpSessionTerminalRunnerState = initialState) { + this.state = structuredClone(initialState); + } + + start(): PvpSessionTerminalRunnerState { + this.startCalls += 1; + this.events.push('runner:start'); + this.state = structuredClone(this.startedState); + return this.getState(); + } + + stop(): PvpSessionTerminalRunnerState { + this.stopCalls += 1; + this.events.push('runner:stop'); + this.state = createRunnerState({ + ...this.state, + running: false, + revision: this.state.revision + 1, + }); + return this.getState(); + } + + getState(): PvpSessionTerminalRunnerState { + return structuredClone(this.state); + } + + subscribe(listener: PvpSessionTerminalRunnerStateListener): () => void { + this.subscribeCount += 1; + this.events.push('runner:subscribe'); + this.listeners.add(listener); + listener(this.getState()); + + return () => { + if (this.listeners.delete(listener)) { + this.unsubscribeCount += 1; + this.events.push('runner:unsubscribe'); + } + }; + } + + submitInputToken(token: string): PvpSessionTerminalSubmitResult { + this.submitTokens.push(token); + this.events.push(`runner:submit:${token}`); + + const snapshot = createPvpSessionTerminalSnapshot(null); + snapshot.screen = this.state.screen; + snapshot.availableInputTokens = [...this.state.availableInputTokens]; + + return { + status: 'invalid_token', + token, + message: 'fake', + snapshot, + }; + } + + pushState(nextState: PvpSessionTerminalRunnerState): void { + this.state = structuredClone(nextState); + const snapshot = this.getState(); + for (const listener of this.listeners) { + listener(snapshot); + } + } +} + +describe('pvp session terminal cli', () => { + it('runs connect bootstrap, starts the runner, and repaints the initial screen on start', async () => { + const initialState = createRunnerState({ + running: false, + revision: 0, + screen: 'screen:idle', + }); + const startedState = createRunnerState({ + running: true, + revision: 1, + screen: 'screen:connected', + availableInputTokens: ['1', 'forfeit'], + }); + const runner = new FakeRunner(initialState, startedState); + const input = new FakeInputSource(); + const output = new FakeScreenOutput(); + const events: string[] = []; + + const cli = createPvpSessionTerminalCli({ + runner, + input, + output, + bootstrap: { + connect: async () => { + events.push('bootstrap:connect'); + }, + }, + }); + + const started = await cli.start(); + + assert.deepEqual(events, ['bootstrap:connect']); + assert.deepEqual(runner.events, ['runner:start', 'runner:subscribe']); + assert.equal(runner.startCalls, 1); + assert.equal(input.subscribeCount, 1); + assert.equal(output.repaints.length, 1); + assert.equal(output.repaints[0]?.screen, 'screen:connected'); + assert.equal(started.running, true); + assert.equal(started.runnerState.running, true); + assert.equal(started.runnerState.revision, 1); + assert.deepEqual(started.runnerState.availableInputTokens, ['1', 'forfeit']); + }); + + it('normalizes input tokens and forwards them to runner.submitInputToken()', async () => { + const runner = new FakeRunner(createRunnerState({ + running: false, + revision: 0, + screen: 'screen:idle', + }), createRunnerState({ + running: true, + revision: 1, + screen: 'screen:connected', + })); + const input = new FakeInputSource(); + const output = new FakeScreenOutput(); + const cli = createPvpSessionTerminalCli({ + runner, + input, + output, + }); + + await cli.start(); + input.emit(' switch:2 '); + input.emit(' '); + + assert.deepEqual(runner.submitTokens, ['switch:2']); + }); + + it('repaints whenever runner state changes after start', async () => { + const runner = new FakeRunner(createRunnerState({ + running: false, + revision: 0, + screen: 'screen:idle', + }), createRunnerState({ + running: true, + revision: 1, + screen: 'screen:connected', + })); + const input = new FakeInputSource(); + const output = new FakeScreenOutput(); + const cli = createPvpSessionTerminalCli({ + runner, + input, + output, + }); + + await cli.start(); + runner.pushState(createRunnerState({ + running: true, + revision: 2, + screen: 'screen:turn-2', + availableInputTokens: ['2'], + })); + + assert.equal(output.repaints.length, 2); + assert.equal(output.repaints[1]?.screen, 'screen:turn-2'); + assert.deepEqual(output.repaints[1]?.state.availableInputTokens, ['2']); + + const cliState: PvpSessionTerminalCliState = cli.getState(); + assert.equal(cliState.running, true); + assert.equal(cliState.runnerState.revision, 2); + assert.equal(cliState.runnerState.screen, 'screen:turn-2'); + }); + + it('cleans up input subscription, runner subscription, runner stop, and ignores later input after stop', async () => { + const runner = new FakeRunner(createRunnerState({ + running: false, + revision: 0, + screen: 'screen:idle', + }), createRunnerState({ + running: true, + revision: 1, + screen: 'screen:connected', + })); + const input = new FakeInputSource(); + const output = new FakeScreenOutput(); + const events: string[] = []; + + const cli = createPvpSessionTerminalCli({ + runner, + input, + output, + bootstrap: { + disconnect: async () => { + events.push('bootstrap:disconnect'); + }, + }, + }); + + await cli.start(); + const stopped = await cli.stop(); + input.emit('1'); + runner.pushState(createRunnerState({ + running: true, + revision: 99, + screen: 'screen:ignored', + })); + + assert.deepEqual(events, ['bootstrap:disconnect']); + assert.equal(input.unsubscribeCount, 1); + assert.equal(runner.unsubscribeCount, 1); + assert.equal(runner.stopCalls, 1); + assert.deepEqual(runner.submitTokens, []); + assert.equal(output.repaints.length, 1); + assert.equal(stopped.running, false); + assert.equal(stopped.runnerState.running, false); + }); +}); diff --git a/test/pvp-session-terminal-controller.test.ts b/test/pvp-session-terminal-controller.test.ts new file mode 100644 index 00000000..72d66c8c --- /dev/null +++ b/test/pvp-session-terminal-controller.test.ts @@ -0,0 +1,297 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + PvpSessionTerminalController, + createBattleCommandEnvelope, + createPvpActionRequestView, + createPvpClientState, + createPvpSessionState, + createPvpSessionTerminalSnapshot, + renderPvpSessionClientScreen, + resolvePvpSessionTerminalInputToken, + type CreateBattleCommandEnvelopeOptions, + type PvpPendingRequest, + type PvpSessionClientState, + type SendPvpSessionBattleCommandResult, +} from '../src/pvp/index.js'; + +function createBaseSessionClientState(): PvpSessionClientState { + const session = createPvpSessionState(); + const protocol = createPvpClientState(); + protocol.session = session; + + return { + transportStatus: 'connected', + session, + protocol, + reconnect: { + autoReconnectEnabled: true, + attempt: 0, + scheduled: false, + delay: null, + nextReconnectAt: null, + lastTrigger: 'manual_connect', + }, + canSendCommand: false, + hasPendingRequest: false, + activeRequestKind: null, + }; +} + +function buildActionRequest(overrides: Partial> = {}): Extract { + return { + kind: 'choose_move_or_switch', + phase: 'awaiting_actions', + turn: 12, + deadlineMs: 30_000, + commandSubmitted: false, + requestId: 'req-action-12', + activePokemon: { + slot: 1, + speciesId: '001', + nickname: 'Bulba', + levelActual: 55, + levelEffective: 52, + hp: 120, + hpMax: 150, + status: 'poison', + fainted: false, + }, + availableMoves: [ + { slot: 2, id: 'growl', disabled: true, currentPp: 40 }, + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + ], + availableSwitches: [ + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: true }, + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + ], + ...overrides, + }; +} + +function setPendingCommand(state: PvpSessionClientState): void { + state.session.pendingCommand = { + clientCommandId: 'cmd-1', + turn: state.session.pendingRequest?.turn ?? 12, + phase: state.session.pendingRequest?.phase ?? 'awaiting_actions', + command: { type: 'choose_move', moveSlot: 1 }, + seq: 1, + sentAt: '2026-04-11T14:00:00.000Z', + status: 'created', + lockedIn: false, + }; +} + +function syncProtocolSession(state: PvpSessionClientState): void { + state.protocol.session = state.session; +} + +class FakeSessionClient { + private state: PvpSessionClientState; + + readonly sentCommands: CreateBattleCommandEnvelopeOptions[] = []; + + throwOnSend: Error | null = null; + + constructor(initialState: PvpSessionClientState) { + this.state = structuredClone(initialState); + } + + getState(): PvpSessionClientState { + return structuredClone(this.state); + } + + sendBattleCommand(options: CreateBattleCommandEnvelopeOptions): SendPvpSessionBattleCommandResult { + this.sentCommands.push(structuredClone(options)); + + if (this.throwOnSend) { + throw this.throwOnSend; + } + + const created = createBattleCommandEnvelope(this.state.session, options); + const protocol = structuredClone(this.state.protocol); + protocol.session = created.state; + + this.state = { + ...this.state, + session: created.state, + protocol, + canSendCommand: false, + hasPendingRequest: created.state.pendingRequest !== null, + activeRequestKind: created.state.pendingRequest?.kind ?? null, + }; + + return { + envelope: created.envelope, + serialized: JSON.stringify(created.envelope), + state: this.getState(), + }; + } +} + +describe('pvp session terminal controller', () => { + it('builds a terminal snapshot with the current plain-text screen and token mappings', () => { + const state = createBaseSessionClientState(); + state.session.roomId = 'room-12'; + state.session.battleId = 'battle-12'; + state.session.battleStatus = 'awaiting_actions'; + state.session.pendingRequest = buildActionRequest(); + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(state); + + const snapshot = createPvpSessionTerminalSnapshot(state); + const actionRequest = createPvpActionRequestView(state); + + assert.equal(snapshot.screen, renderPvpSessionClientScreen(state)); + assert.deepEqual(snapshot.availableInputTokens, ['1', 'switch:2', 'forfeit']); + assert.deepEqual(snapshot.inputEntries.map((entry) => entry.inputToken), ['1', '2', 'switch:2', 'switch:3', 'forfeit']); + assert.equal(snapshot.actionRequest?.requestId, actionRequest?.requestId ?? null); + assert.deepEqual(snapshot.inputEntries[0]?.command, { type: 'choose_move', moveSlot: 1 }); + assert.equal(snapshot.inputEntries[0]?.section.id, 'moves'); + }); + + it('resolves current menu input tokens back to authoritative battle commands', () => { + const state = createBaseSessionClientState(); + state.session.battleStatus = 'awaiting_actions'; + state.session.pendingRequest = buildActionRequest(); + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(state); + + const result = resolvePvpSessionTerminalInputToken(state, 'switch:2'); + + assert.equal(result.status, 'resolved'); + assert.deepEqual(result.command, { type: 'choose_switch', targetSlot: 2 }); + assert.equal(result.entry.inputToken, 'switch:2'); + }); + + it('submits a valid input token through sessionClient.sendBattleCommand(...)', () => { + const state = createBaseSessionClientState(); + state.session.roomId = 'room-12'; + state.session.battleId = 'battle-12'; + state.session.battleStatus = 'awaiting_actions'; + state.session.pendingRequest = buildActionRequest(); + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(state); + + const client = new FakeSessionClient(state); + const controller = new PvpSessionTerminalController({ + sessionClient: client, + now: () => new Date('2026-04-11T16:30:00.000Z'), + createClientCommandId: (_state, command) => `det-${command.type}`, + }); + + const result = controller.submitInputToken('1'); + + assert.equal(result.status, 'submitted'); + assert.equal(client.sentCommands.length, 1); + assert.deepEqual(client.sentCommands[0], { + clientCommandId: 'det-choose_move', + sentAt: '2026-04-11T16:30:00.000Z', + command: { type: 'choose_move', moveSlot: 1 }, + }); + assert.equal(result.sendResult.envelope.payload.clientCommandId, 'det-choose_move'); + assert.equal(result.snapshot.availableInputTokens.includes('1'), true); + }); + + it('returns invalid_token deterministically when the token does not match the current request', () => { + const state = createBaseSessionClientState(); + state.session.battleStatus = 'awaiting_actions'; + state.session.pendingRequest = buildActionRequest(); + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(state); + + const controller = new PvpSessionTerminalController({ + sessionClient: new FakeSessionClient(state), + }); + + const result = controller.submitInputToken('switch:999'); + + assert.equal(result.status, 'invalid_token'); + }); + + it('returns no_request when there is no current pending request', () => { + const state = createBaseSessionClientState(); + state.session.battleStatus = 'awaiting_actions'; + syncProtocolSession(state); + + const controller = new PvpSessionTerminalController({ + sessionClient: new FakeSessionClient(state), + }); + + const result = controller.submitInputToken('1'); + + assert.equal(result.status, 'no_request'); + }); + + it('returns locked when the pending request already has a submitted/pending command', () => { + const state = createBaseSessionClientState(); + state.session.battleStatus = 'awaiting_actions'; + state.session.pendingRequest = buildActionRequest(); + state.canSendCommand = false; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + setPendingCommand(state); + syncProtocolSession(state); + + const controller = new PvpSessionTerminalController({ + sessionClient: new FakeSessionClient(state), + }); + + const result = controller.submitInputToken('1'); + + assert.equal(result.status, 'locked'); + }); + + it('returns transport_not_ready before trying to submit when the socket is not connected', () => { + const state = createBaseSessionClientState(); + state.transportStatus = 'reconnecting'; + state.session.battleStatus = 'awaiting_actions'; + state.session.pendingRequest = buildActionRequest(); + state.canSendCommand = false; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(state); + + const client = new FakeSessionClient(state); + const controller = new PvpSessionTerminalController({ + sessionClient: client, + }); + + const result = controller.submitInputToken('1'); + + assert.equal(result.status, 'transport_not_ready'); + assert.equal(client.sentCommands.length, 0); + }); + + it('returns unavailable when the underlying transport wrapper throws unexpectedly', () => { + const state = createBaseSessionClientState(); + state.session.roomId = 'room-12'; + state.session.battleId = 'battle-12'; + state.session.battleStatus = 'awaiting_actions'; + state.session.pendingRequest = buildActionRequest(); + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(state); + + const client = new FakeSessionClient(state); + client.throwOnSend = new Error('socket unexpectedly missing'); + const controller = new PvpSessionTerminalController({ + sessionClient: client, + }); + + const result = controller.submitInputToken('1'); + + assert.equal(result.status, 'unavailable'); + assert.match(result.message, /socket unexpectedly missing/); + }); +}); diff --git a/test/pvp-session-terminal-runner.test.ts b/test/pvp-session-terminal-runner.test.ts new file mode 100644 index 00000000..55668ef4 --- /dev/null +++ b/test/pvp-session-terminal-runner.test.ts @@ -0,0 +1,281 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createBattleCommandEnvelope, + createPvpClientState, + createPvpSessionState, + createPvpSessionTerminalRunner, + renderPvpSessionClientScreen, + type CreateBattleCommandEnvelopeOptions, + type PvpPendingRequest, + type PvpSessionClientState, + type PvpSessionTerminalRunnerState, + type SendPvpSessionBattleCommandResult, +} from '../src/pvp/index.js'; + +function createBaseSessionClientState(): PvpSessionClientState { + const session = createPvpSessionState(); + const protocol = createPvpClientState(); + protocol.session = session; + + return { + transportStatus: 'connected', + session, + protocol, + reconnect: { + autoReconnectEnabled: true, + attempt: 0, + scheduled: false, + delay: null, + nextReconnectAt: null, + lastTrigger: 'manual_connect', + }, + canSendCommand: false, + hasPendingRequest: false, + activeRequestKind: null, + }; +} + +function buildActionRequest(overrides: Partial> = {}): Extract { + return { + kind: 'choose_move_or_switch', + phase: 'awaiting_actions', + turn: 12, + deadlineMs: 30_000, + commandSubmitted: false, + requestId: 'req-action-12', + activePokemon: { + slot: 1, + speciesId: '001', + nickname: 'Bulba', + levelActual: 55, + levelEffective: 52, + hp: 120, + hpMax: 150, + status: 'poison', + fainted: false, + }, + availableMoves: [ + { slot: 2, id: 'growl', disabled: true, currentPp: 40 }, + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + ], + availableSwitches: [ + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: true }, + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + ], + ...overrides, + }; +} + +function syncProtocolSession(state: PvpSessionClientState): void { + state.protocol.session = state.session; +} + +class FakeSessionClient { + private state: PvpSessionClientState; + + private readonly listeners = new Set<(state: PvpSessionClientState) => void>(); + + readonly sentCommands: CreateBattleCommandEnvelopeOptions[] = []; + + constructor(initialState: PvpSessionClientState) { + this.state = structuredClone(initialState); + } + + getState(): PvpSessionClientState { + return structuredClone(this.state); + } + + subscribe(listener: (state: PvpSessionClientState) => void): () => void { + this.listeners.add(listener); + listener(this.getState()); + + return () => { + this.listeners.delete(listener); + }; + } + + pushState(nextState: PvpSessionClientState): void { + this.state = structuredClone(nextState); + const snapshot = this.getState(); + for (const listener of this.listeners) { + listener(snapshot); + } + } + + sendBattleCommand(options: CreateBattleCommandEnvelopeOptions): SendPvpSessionBattleCommandResult { + this.sentCommands.push(structuredClone(options)); + + const created = createBattleCommandEnvelope(this.state.session, options); + const protocol = structuredClone(this.state.protocol); + protocol.session = created.state; + + this.state = { + ...this.state, + session: created.state, + protocol, + canSendCommand: false, + hasPendingRequest: created.state.pendingRequest !== null, + activeRequestKind: created.state.pendingRequest?.kind ?? null, + }; + + return { + envelope: created.envelope, + serialized: JSON.stringify(created.envelope), + state: this.getState(), + }; + } +} + +describe('pvp session terminal runner', () => { + it('starts a live subscription and emits the latest deterministic snapshot state', () => { + const state = createBaseSessionClientState(); + state.session.roomId = 'room-12'; + state.session.battleId = 'battle-12'; + state.session.battleStatus = 'awaiting_actions'; + state.session.pendingRequest = buildActionRequest(); + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(state); + + const client = new FakeSessionClient(state); + const runner = createPvpSessionTerminalRunner({ + sessionClient: client, + }); + const observed: PvpSessionTerminalRunnerState[] = []; + const unsubscribe = runner.subscribe((runnerState) => { + observed.push(runnerState); + }); + + assert.equal(observed.length, 1); + assert.equal(observed[0]?.running, false); + assert.equal(observed[0]?.revision, 0); + + const started = runner.start(); + + assert.equal(started.running, true); + assert.equal(started.revision, 1); + assert.equal(started.screen, renderPvpSessionClientScreen(client.getState())); + assert.deepEqual(started.availableInputTokens, ['1', 'switch:2', 'forfeit']); + assert.equal(observed.length, 2); + assert.equal(observed[1]?.running, true); + assert.equal(observed[1]?.revision, 1); + + unsubscribe(); + }); + + it('propagates live session updates while running', () => { + const initialState = createBaseSessionClientState(); + initialState.session.battleStatus = 'awaiting_actions'; + initialState.session.pendingRequest = buildActionRequest(); + initialState.canSendCommand = true; + initialState.hasPendingRequest = true; + initialState.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(initialState); + + const client = new FakeSessionClient(initialState); + const runner = createPvpSessionTerminalRunner({ + sessionClient: client, + }); + + runner.start(); + + const nextState = client.getState(); + nextState.transportStatus = 'reconnecting'; + nextState.reconnect = { + autoReconnectEnabled: true, + attempt: 2, + scheduled: true, + delay: 2_000, + nextReconnectAt: '2026-04-12T10:00:02.000Z', + lastTrigger: 'transport_close', + }; + nextState.session.pendingRequest = null; + nextState.session.battleStatus = 'in_progress'; + nextState.canSendCommand = false; + nextState.hasPendingRequest = false; + nextState.activeRequestKind = null; + syncProtocolSession(nextState); + + client.pushState(nextState); + + const runnerState = runner.getState(); + assert.equal(runnerState.running, true); + assert.equal(runnerState.revision, 2); + assert.deepEqual(runnerState.availableInputTokens, []); + assert.match(runnerState.screen, /상태: 재접속 중/); + assert.equal(runnerState.lastSubmitResult, null); + }); + + it('recomputes the latest snapshot after submit and stores the last submit result', () => { + const state = createBaseSessionClientState(); + state.session.roomId = 'room-12'; + state.session.battleId = 'battle-12'; + state.session.battleStatus = 'awaiting_actions'; + state.session.pendingRequest = buildActionRequest(); + state.canSendCommand = true; + state.hasPendingRequest = true; + state.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(state); + + const client = new FakeSessionClient(state); + const runner = createPvpSessionTerminalRunner({ + sessionClient: client, + }); + + runner.start(); + const result = runner.submitInputToken('1'); + const runnerState = runner.getState(); + + assert.equal(result.status, 'submitted'); + assert.equal(client.sentCommands.length, 1); + assert.equal(runnerState.revision, 2); + assert.equal(runnerState.lastSubmitResult?.status, 'submitted'); + assert.deepEqual(runnerState.lastSubmitResult?.snapshot, runnerState.snapshot); + assert.equal(runnerState.snapshot.state?.session.pendingCommand?.command.type, 'choose_move'); + assert.equal(runnerState.snapshot.state?.canSendCommand, false); + }); + + it('stops forwarding session-client updates after stop()', () => { + const initialState = createBaseSessionClientState(); + initialState.session.battleStatus = 'awaiting_actions'; + initialState.session.pendingRequest = buildActionRequest(); + initialState.canSendCommand = true; + initialState.hasPendingRequest = true; + initialState.activeRequestKind = 'choose_move_or_switch'; + syncProtocolSession(initialState); + + const client = new FakeSessionClient(initialState); + const runner = createPvpSessionTerminalRunner({ + sessionClient: client, + }); + + const observed: PvpSessionTerminalRunnerState[] = []; + runner.subscribe((runnerState) => { + observed.push(runnerState); + }); + + runner.start(); + const stopped = runner.stop(); + const revisionAfterStop = stopped.revision; + + assert.equal(stopped.running, false); + + const nextState = client.getState(); + nextState.transportStatus = 'closed'; + nextState.session.battleStatus = 'finished'; + nextState.session.pendingRequest = null; + nextState.hasPendingRequest = false; + nextState.canSendCommand = false; + nextState.activeRequestKind = null; + syncProtocolSession(nextState); + + client.pushState(nextState); + + assert.equal(runner.getState().revision, revisionAfterStop); + assert.equal(runner.getState().running, false); + assert.equal(observed.at(-1)?.revision, revisionAfterStop); + }); +}); diff --git a/test/pvp-session-terminal-stdio.test.ts b/test/pvp-session-terminal-stdio.test.ts new file mode 100644 index 00000000..fbf8d1d5 --- /dev/null +++ b/test/pvp-session-terminal-stdio.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { describe, it } from 'node:test'; + +import { + createPvpSessionTerminalSnapshot, + createPvpSessionTerminalStdioAdapter, + type PvpSessionTerminalRunnerState, + type PvpSessionTerminalStdioAbortEvent, +} from '../src/pvp/index.js'; +import { CLEAR_SCREEN, CURSOR_HOME, HIDE_CURSOR, SHOW_CURSOR } from '../src/battle-tui/ansi.js'; + +function createRunnerState(overrides: Partial = {}): PvpSessionTerminalRunnerState { + const snapshot = createPvpSessionTerminalSnapshot(null); + snapshot.screen = overrides.screen ?? 'screen:idle'; + snapshot.availableInputTokens = [...(overrides.availableInputTokens ?? [])]; + + return { + running: overrides.running ?? false, + revision: overrides.revision ?? 0, + snapshot, + screen: overrides.screen ?? snapshot.screen, + availableInputTokens: [...(overrides.availableInputTokens ?? snapshot.availableInputTokens)], + lastSubmitResult: overrides.lastSubmitResult ?? null, + }; +} + +class FakeStdin extends EventEmitter { + isTTY: boolean; + + readonly rawModeCalls: boolean[] = []; + + readonly encodings: string[] = []; + + resumeCalls = 0; + + pauseCalls = 0; + + constructor(options: { isTTY: boolean }) { + super(); + this.isTTY = options.isTTY; + } + + setRawMode(enabled: boolean): void { + this.rawModeCalls.push(enabled); + } + + resume(): void { + this.resumeCalls += 1; + } + + pause(): void { + this.pauseCalls += 1; + } + + setEncoding(encoding: string): void { + this.encodings.push(encoding); + } +} + +class FakeStdout { + readonly writes: string[] = []; + + write(chunk: string): boolean { + this.writes.push(chunk); + return true; + } +} + +class FakeReadline extends EventEmitter { + closeCalls = 0; + + close(): void { + this.closeCalls += 1; + this.emit('close'); + } +} + +describe('pvp session terminal stdio adapter', () => { + it('maps tty single-key aliases from the latest rendered state and restores raw mode on unsubscribe', () => { + const stdin = new FakeStdin({ isTTY: true }); + const stdout = new FakeStdout(); + const signals = new EventEmitter(); + const aborts: PvpSessionTerminalStdioAbortEvent[] = []; + const adapter = createPvpSessionTerminalStdioAdapter({ + stdin, + stdout, + signalTarget: signals, + onAbort: (event) => { + aborts.push(event); + }, + }); + const observed: string[] = []; + + adapter.repaint('screen:turn', createRunnerState({ + running: true, + revision: 2, + screen: 'screen:turn', + availableInputTokens: ['switch:2', 'forfeit'], + })); + + const unsubscribe = adapter.subscribe((token) => { + observed.push(token); + }); + + stdin.emit('data', '2'); + stdin.emit('data', 'f'); + stdin.emit('data', 'switch:2'); + stdin.emit('data', '\x03'); + + assert.deepEqual(observed, ['switch:2', 'forfeit', 'switch:2']); + assert.equal(aborts.length, 1); + assert.equal(aborts[0]?.reason, 'sigint'); + assert.deepEqual(stdin.rawModeCalls, [true]); + assert.equal(stdin.resumeCalls, 1); + assert.deepEqual(stdin.encodings, ['utf8']); + assert.equal((stdout.writes[0] ?? '').includes(`${HIDE_CURSOR}${CLEAR_SCREEN}${CURSOR_HOME}screen:turn`), true); + + unsubscribe(); + + assert.deepEqual(stdin.rawModeCalls, [true, false]); + assert.equal(stdin.pauseCalls, 1); + assert.equal(stdout.writes.at(-1), SHOW_CURSOR); + }); + + it('uses readline fallback for non-tty stdin and does not emit abort while closing explicitly', () => { + const stdin = new FakeStdin({ isTTY: false }); + const stdout = new FakeStdout(); + const signals = new EventEmitter(); + const aborts: PvpSessionTerminalStdioAbortEvent[] = []; + const readline = new FakeReadline(); + const adapter = createPvpSessionTerminalStdioAdapter({ + stdin, + stdout, + signalTarget: signals, + createReadlineInterface: () => readline, + onAbort: (event) => { + aborts.push(event); + }, + }); + const observed: string[] = []; + + adapter.repaint('screen:switch', createRunnerState({ + running: true, + revision: 3, + screen: 'screen:switch', + availableInputTokens: ['1', 'switch:2', 'forfeit'], + })); + + const unsubscribe = adapter.subscribe((token) => { + observed.push(token); + }); + + readline.emit('line', ' 2 '); + readline.emit('line', ' forfeit '); + + assert.deepEqual(observed, ['switch:2', 'forfeit']); + + unsubscribe(); + + assert.equal(readline.closeCalls, 1); + assert.equal(aborts.length, 0); + }); + + it('treats unexpected readline close as eof abort', () => { + const stdin = new FakeStdin({ isTTY: false }); + const stdout = new FakeStdout(); + const aborts: PvpSessionTerminalStdioAbortEvent[] = []; + const readline = new FakeReadline(); + const adapter = createPvpSessionTerminalStdioAdapter({ + stdin, + stdout, + createReadlineInterface: () => readline, + onAbort: (event) => { + aborts.push(event); + }, + }); + + const unsubscribe = adapter.subscribe(() => {}); + readline.emit('close'); + + assert.equal(aborts.length, 1); + assert.equal(aborts[0]?.reason, 'eof'); + + unsubscribe(); + }); +}); diff --git a/test/pvp-timeout-policy.test.ts b/test/pvp-timeout-policy.test.ts new file mode 100644 index 00000000..6b67040a --- /dev/null +++ b/test/pvp-timeout-policy.test.ts @@ -0,0 +1,418 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; + +import { initLocale } from '../src/i18n/index.js'; +import type { MoveData, PokemonData } from '../src/core/types.js'; +import { + BattleSessionService, + type BattleCommandEnvelope, + type BattleDataResolver, +} from '../src/server/battle/index.js'; +import { + InMemoryPartySnapshotRepository, + PartyRegistrationService, + type GrowthProofInput, + type OnlinePartyMemberInput, +} from '../src/server/parties/index.js'; +import type { PvpGeneration } from '../src/server/rules/index.js'; +import { InMemoryRoomRepository, RoomService } from '../src/server/rooms/index.js'; +import { PvpWsServer, type PvpWsOutboundEnvelope } from '../src/server/ws/index.js'; + +initLocale('ko'); + +const SPECIES_DATA: Record = { + '001': { + id: 1, + name: 'Bulbasaur', + types: ['grass'], + stage: 1, + line: ['Bulbasaur'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 80, attack: 85, defense: 80, speed: 90, sp_attack: 95, sp_defense: 85 }, + catch_rate: 45, + }, + '004': { + id: 4, + name: 'Charmander', + types: ['fire'], + stage: 1, + line: ['Charmander'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 78, attack: 84, defense: 72, speed: 88, sp_attack: 100, sp_defense: 78 }, + catch_rate: 45, + }, + '007': { + id: 7, + name: 'Squirtle', + types: ['water'], + stage: 1, + line: ['Squirtle'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 79, attack: 83, defense: 100, speed: 60, sp_attack: 85, sp_defense: 105 }, + catch_rate: 45, + }, + '025': { + id: 25, + name: 'Pikachu', + types: ['electric'], + stage: 1, + line: ['Pikachu'], + evolves_at: null, + unlock: 'starter', + exp_group: 'medium_fast', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 70, attack: 60, defense: 55, speed: 110, sp_attack: 70, sp_defense: 60 }, + catch_rate: 190, + }, + '039': { + id: 39, + name: 'Jigglypuff', + types: ['normal'], + stage: 1, + line: ['Jigglypuff'], + evolves_at: null, + unlock: 'wild', + exp_group: 'fast', + rarity: 'uncommon', + region: 'kanto', + base_stats: { hp: 135, attack: 65, defense: 45, speed: 20, sp_attack: 65, sp_defense: 50 }, + catch_rate: 170, + }, + '052': { + id: 52, + name: 'Meowth', + types: ['normal'], + stage: 1, + line: ['Meowth'], + evolves_at: 28, + unlock: 'wild', + exp_group: 'medium_fast', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 60, attack: 70, defense: 55, speed: 110, sp_attack: 45, sp_defense: 65 }, + catch_rate: 255, + }, +}; + +const MOVE_DATA: Record = { + 'host-fast': { + id: 101, + name: 'host-fast', + nameKo: '호스트 속공', + nameEn: 'Host Fast', + type: 'normal', + category: 'physical', + power: 55, + accuracy: 100, + pp: 20, + }, + 'host-chip': { + id: 102, + name: 'host-chip', + nameKo: '호스트 견제', + nameEn: 'Host Chip', + type: 'grass', + category: 'special', + power: 35, + accuracy: 100, + pp: 25, + }, + 'guest-fast': { + id: 201, + name: 'guest-fast', + nameKo: '게스트 속공', + nameEn: 'Guest Fast', + type: 'normal', + category: 'physical', + power: 50, + accuracy: 100, + pp: 20, + }, + 'guest-chip': { + id: 202, + name: 'guest-chip', + nameKo: '게스트 견제', + nameEn: 'Guest Chip', + type: 'fire', + category: 'special', + power: 30, + accuracy: 100, + pp: 25, + }, +}; + +const RESOLVER: BattleDataResolver = { + resolveSpecies(_generation, speciesId) { + return SPECIES_DATA[speciesId] ?? SPECIES_DATA[speciesId.padStart(3, '0')]; + }, + resolveMove(_generation, moveId) { + return MOVE_DATA[moveId]; + }, +}; + +class FakeTransport { + readonly messages: PvpWsOutboundEnvelope[] = []; + readonly closes: Array<{ code: number; reason: string }> = []; + + send(message: PvpWsOutboundEnvelope): void { + this.messages.push(message); + } + + close(code: number, reason: string): void { + this.closes.push({ code, reason }); + } +} + +function makeMember(slot: number, speciesId: string, levelActual: number, moves: string[]): OnlinePartyMemberInput { + return { + slot, + pokemonInstanceId: `pkm-${slot}`, + speciesId, + nickname: `P-${slot}`, + levelActual, + moves, + }; +} + +function makeGrowthProof(members: OnlinePartyMemberInput[]): GrowthProofInput { + return { + proofVersion: 'v1', + capturedAt: '2026-04-11T09:00:00Z', + sourceSaveId: 'save_main', + sourceSaveRevision: 101, + cheatFlags: { + hasCheatHistory: false, + flags: [], + }, + memberProofs: members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId, + speciesId: member.speciesId, + levelActual: member.levelActual, + movesHash: `sha256:moves-${member.slot}`, + stateHash: `sha256:state-${member.slot}`, + })), + }; +} + +function createClock() { + let current = Date.UTC(2026, 3, 11, 8, 0, 0); + + return { + now: () => new Date(current), + advance(ms: number) { + current += ms; + }, + }; +} + +function createEnvironment() { + const clock = createClock(); + const partyRepository = new InMemoryPartySnapshotRepository(); + const roomRepository = new InMemoryRoomRepository(); + const partyService = new PartyRegistrationService({ repository: partyRepository }); + let roomCodeIndex = 0; + let battleIdIndex = 0; + let battleSeedIndex = 0; + const roomService = new RoomService({ + repository: roomRepository, + partyService, + now: clock.now, + roomCodeGenerator: () => ['A7KQ2M', 'B8TR4N'][roomCodeIndex++] ?? 'Z9YX8W', + battleSeedGenerator: () => `bseed_${++battleSeedIndex}`, + roomTtlMs: 15 * 60 * 1000, + }); + const battleSessionService = new BattleSessionService({ + dataResolver: RESOLVER, + now: clock.now, + battleIdGenerator: () => `battle_${String(++battleIdIndex).padStart(6, '0')}`, + }); + + const registerParty = (playerId: string, generation: PvpGeneration = 'gen1') => { + const leadMoves = + playerId === 'host-user' + ? ['host-fast', 'host-chip', 'guest-fast', 'guest-chip'] + : ['guest-fast', 'guest-chip', 'host-fast', 'host-chip']; + const members: OnlinePartyMemberInput[] = [ + makeMember(1, playerId === 'host-user' ? '001' : '004', 52, leadMoves), + makeMember(2, '007', 48, ['host-chip', 'guest-chip', 'host-fast', 'guest-fast']), + makeMember(3, '025', 45, ['guest-fast', 'host-fast', 'guest-chip', 'host-chip']), + makeMember(4, '039', 40, ['guest-chip', 'host-chip', 'guest-fast', 'host-fast']), + makeMember(5, '052', 35, ['host-fast', 'guest-fast', 'host-chip', 'guest-chip']), + makeMember(6, playerId === 'host-user' ? '004' : '001', 30, ['guest-chip', 'guest-fast', 'host-chip', 'host-fast']), + ]; + + return partyService.registerActiveParty({ + playerId, + generation, + sourceStateHash: `sha256:${playerId}:state`, + sourceConfigHash: `sha256:${playerId}:config`, + clientBuild: 'tokenmon-cli/0.120.0', + members, + growthProof: makeGrowthProof(members), + }).party; + }; + + const hostParty = registerParty('host-user'); + const guestParty = registerParty('guest-user'); + const room = roomService.createRoom({ + playerId: 'host-user', + generation: 'gen1', + visibility: 'private_friend', + }); + const joinedRoom = roomService.joinRoom({ + playerId: 'guest-user', + roomId: room.room.roomId, + roomCode: room.room.roomCode, + generation: 'gen1', + }); + + const server = new PvpWsServer({ + authenticate(token) { + if (token === 'host-token') { + return { userId: 'host-user' }; + } + if (token === 'guest-token') { + return { userId: 'guest-user' }; + } + return null; + }, + now: clock.now, + roomRepository, + battleSessionService, + loadPartySnapshot(snapshotId) { + return [hostParty, guestParty].find((party) => party.snapshotId === snapshotId); + }, + }); + + return { clock, room: joinedRoom, server }; +} + +function getBattleId(messages: PvpWsOutboundEnvelope[]): string { + const snapshot = messages.find((message) => message.type === 'room.snapshot'); + assert.ok(snapshot, 'room.snapshot message missing'); + return snapshot.battleId; +} + +function buildChooseMoveCommand(input: { + roomId: string; + battleId: string; + clientCommandId: string; + turn: number; + moveSlot: number; +}): BattleCommandEnvelope { + return { + type: 'battle.command', + roomId: input.roomId, + battleId: input.battleId, + seq: 1, + sentAt: '2026-04-11T08:00:00.000Z', + payload: { + clientCommandId: input.clientCommandId, + turn: input.turn, + phase: 'awaiting_actions', + command: { + type: 'choose_move', + moveSlot: input.moveSlot, + }, + }, + }; +} + +describe('PvP timeout policy', () => { + beforeEach(() => { + initLocale('ko'); + }); + + it('액션 타임아웃이 나면 서버가 기본 유효 명령을 자동 제출해 턴을 진행한다', () => { + const { clock, room, server } = createEnvironment(); + const hostTransport = new FakeTransport(); + const guestTransport = new FakeTransport(); + + server.connectClient({ roomId: room.room.roomId, token: 'host-token', connectionId: 'conn-host', transport: hostTransport }); + server.connectClient({ roomId: room.room.roomId, token: 'guest-token', connectionId: 'conn-guest', transport: guestTransport }); + + clock.advance(45_001); + const sweep = server.sweepBattleTimeouts(); + + assert.equal(sweep.processedBattles, 1); + assert.equal(hostTransport.messages.some((message) => message.type === 'battle.turn_resolved'), true); + assert.equal(guestTransport.messages.some((message) => message.type === 'battle.turn_resolved'), true); + + const session = server.getBattleSession(room.room.roomId); + assert.equal(session?.turn, 2); + const debugView = server.getBattleDebugView(room.room.roomId); + assert.equal(debugView?.timeouts.host.consecutive, 1); + assert.equal(debugView?.timeouts.guest.consecutive, 1); + assert.equal(debugView?.commands.filter((entry) => entry.source === 'timeout_auto').length, 2); + }); + + it('연속 timeout이 누적되면 timeout_forfeit로 종료한다', () => { + const { clock, room, server } = createEnvironment(); + const hostTransport = new FakeTransport(); + const guestTransport = new FakeTransport(); + + server.connectClient({ roomId: room.room.roomId, token: 'host-token', connectionId: 'conn-host', transport: hostTransport }); + server.connectClient({ roomId: room.room.roomId, token: 'guest-token', connectionId: 'conn-guest', transport: guestTransport }); + + const battleId = getBattleId(hostTransport.messages); + + server.receiveMessage( + 'conn-host', + buildChooseMoveCommand({ roomId: room.room.roomId, battleId, clientCommandId: 'host-cmd-1', turn: 1, moveSlot: 1 }), + ); + clock.advance(45_001); + server.sweepBattleTimeouts(); + + server.receiveMessage( + 'conn-host', + buildChooseMoveCommand({ roomId: room.room.roomId, battleId, clientCommandId: 'host-cmd-2', turn: 2, moveSlot: 1 }), + ); + clock.advance(45_001); + server.sweepBattleTimeouts(); + + const ended = hostTransport.messages.findLast((message) => message.type === 'battle.ended'); + assert.ok(ended && ended.type === 'battle.ended'); + assert.equal(ended.payload.reason, 'timeout_forfeit'); + const session = server.getBattleSession(room.room.roomId); + assert.equal(session?.phase, 'finished'); + assert.equal(session?.result?.loserSeat, 'guest'); + + const debugView = server.getBattleDebugView(room.room.roomId); + assert.equal(debugView?.timeouts.guest.consecutive, 2); + }); + + it('deadline이 지난 뒤 도착한 클라이언트 명령은 timeout rejection으로 거부한다', () => { + const { clock, room, server } = createEnvironment(); + const hostTransport = new FakeTransport(); + const guestTransport = new FakeTransport(); + + server.connectClient({ roomId: room.room.roomId, token: 'host-token', connectionId: 'conn-host', transport: hostTransport }); + server.connectClient({ roomId: room.room.roomId, token: 'guest-token', connectionId: 'conn-guest', transport: guestTransport }); + + const battleId = getBattleId(hostTransport.messages); + clock.advance(45_001); + + server.receiveMessage( + 'conn-guest', + buildChooseMoveCommand({ roomId: room.room.roomId, battleId, clientCommandId: 'guest-late', turn: 1, moveSlot: 1 }), + ); + + const rejected = guestTransport.messages.findLast((message) => message.type === 'battle.command_rejected'); + assert.ok(rejected && rejected.type === 'battle.command_rejected'); + assert.equal(rejected.payload.code, 'PVP_COMMAND_TIMEOUT'); + }); +}); diff --git a/test/pvp-turn-result-view.test.ts b/test/pvp-turn-result-view.test.ts new file mode 100644 index 00000000..97e31c9d --- /dev/null +++ b/test/pvp-turn-result-view.test.ts @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createPvpSessionState, + createPvpTurnResultView, + createPvpTurnResultViewFromPayload, + type PvpSessionState, + type PvpTurnResolvedPayloadLike, +} from '../src/pvp/index.js'; + +function createVisibleState() { + return { + self: { + active: { + slot: 1, + speciesId: '025', + nickname: 'Pika', + levelActual: 66, + levelEffective: 60, + hp: 88, + hpMax: 120, + status: null, + fainted: false, + }, + bench: [ + { slot: 2, speciesId: '133', nickname: 'Eevee', fainted: false }, + { slot: 3, speciesId: '143', nickname: 'Snorlax', fainted: true }, + ], + }, + opponent: { + active: { + slot: 1, + speciesId: '006', + nickname: 'Zard', + levelActual: 72, + levelEffective: 60, + hp: 30, + hpMax: 150, + status: 'burn', + fainted: false, + }, + benchCount: 2, + }, + }; +} + +function createSessionState(): { session: PvpSessionState } { + const session = createPvpSessionState(); + return { session }; +} + +describe('pvp turn result view', () => { + it('renders a 기본 액션 턴 payload into deterministic Korean log entries and summary labels', () => { + const payload: PvpTurnResolvedPayloadLike = { + turn: 7, + nextPhase: 'awaiting_actions', + postTurnVisibleState: createVisibleState(), + events: [ + { + eventType: 'move_used', + actor: 'self', + actorSlot: 1, + actorSpeciesId: '025', + moveSlot: 1, + moveId: 'thunderbolt', + }, + { + eventType: 'damage_applied', + target: 'opponent', + targetSlot: 1, + targetSpeciesId: '006', + hp: 30, + hpMax: 150, + damage: 40, + fainted: false, + }, + { + eventType: 'heal_applied', + target: 'self', + targetSlot: 1, + targetSpeciesId: '025', + hp: 88, + hpMax: 120, + heal: 12, + }, + { + eventType: 'status_applied', + target: 'opponent', + targetSlot: 1, + targetSpeciesId: '006', + status: 'burn', + }, + ], + }; + + const view = createPvpTurnResultViewFromPayload(payload); + + assert.ok(view); + assert.equal(view.source, 'payload'); + assert.equal(view.turn, 7); + assert.equal(view.title, '턴 7 결과'); + assert.equal(view.eventCount, 4); + assert.equal(view.hasEventLog, true); + assert.deepEqual( + view.logs.map((entry) => ({ title: entry.title, message: entry.message, emphasis: entry.emphasis })), + [ + { + title: '기술 사용', + message: '내 025 (슬롯 1)이(가) thunderbolt 사용', + emphasis: 'neutral', + }, + { + title: '피해', + message: '상대 006 (슬롯 1) HP 30/150 (-40)', + emphasis: 'positive', + }, + { + title: '회복', + message: '내 025 (슬롯 1) HP 88/120 (+12)', + emphasis: 'positive', + }, + { + title: '상태이상', + message: '상대 006 (슬롯 1) 상태이상 화상', + emphasis: 'positive', + }, + ], + ); + assert.equal(view.summary.self.activeLabel, 'Pika (025) · 슬롯 1 · Lv.60 (실레벨 66) · HP 88/120'); + assert.equal(view.summary.self.benchLabel, '벤치 2마리 · 출전 가능 1 · 기절 1'); + assert.equal(view.summary.self.remainingBenchCount, 1); + assert.deepEqual(view.summary.self.benchEntries, [ + { slot: 2, label: 'Eevee (133) · 슬롯 2 · 대기', fainted: false }, + { slot: 3, label: 'Snorlax (143) · 슬롯 3 · 기절', fainted: true }, + ]); + assert.equal(view.summary.opponent.activeLabel, 'Zard (006) · 슬롯 1 · Lv.60 (실레벨 72) · HP 30/150 · 상태 화상'); + assert.equal(view.summary.opponent.benchLabel, '상대 벤치 2칸 비공개'); + assert.equal(view.summary.nextPhaseLabel, '다음 행동 선택'); + assert.equal(view.summary.statusLabel, '다음 턴 진행 가능'); + assert.equal(view.summary.terminalResultLabel, null); + }); + + it('renders a 교체 유도 턴 payload with fainting and replacement events', () => { + const payload: PvpTurnResolvedPayloadLike = { + turn: 8, + nextPhase: 'awaiting_replacement', + postTurnVisibleState: createVisibleState(), + events: [ + { + eventType: 'switch_used', + actor: 'opponent', + fromSlot: 2, + toSlot: 1, + speciesId: '130', + }, + { + eventType: 'damage_applied', + target: 'self', + targetSlot: 1, + targetSpeciesId: '025', + hp: 0, + hpMax: 120, + damage: 88, + fainted: true, + }, + { + eventType: 'pokemon_fainted', + target: 'self', + targetSlot: 1, + targetSpeciesId: '025', + }, + { + eventType: 'replacement_selected', + actor: 'self', + slot: 2, + speciesId: '133', + }, + ], + }; + + const view = createPvpTurnResultViewFromPayload(payload); + + assert.ok(view); + assert.equal(view.summary.nextPhase, 'awaiting_replacement'); + assert.equal(view.summary.nextPhaseLabel, '교체 포켓몬 선택'); + assert.equal(view.summary.statusLabel, '교체 필요'); + assert.deepEqual( + view.logs.map((entry) => entry.message), + [ + '상대 130 · 슬롯 2 → 1 교체', + '내 025 (슬롯 1) HP 0/120 (-88) · 기절', + '내 025 (슬롯 1) 기절', + '내 133 · 슬롯 2 선택', + ], + ); + assert.deepEqual(view.logs.map((entry) => entry.emphasis), ['neutral', 'negative', 'negative', 'neutral']); + }); + + it('fills terminal summary from session state for finished turns and supports session-only fallback', () => { + const state = createSessionState(); + state.session.visibleState = createVisibleState(); + state.session.lastResolvedTurn = 9; + state.session.battleStatus = 'finished'; + state.session.terminalResult = { + result: 'win', + reason: 'forfeit', + finalVisibleState: { + self: { remainingCount: 3 }, + opponent: { remainingCount: 0 }, + }, + }; + + const payload: PvpTurnResolvedPayloadLike = { + turn: 9, + nextPhase: 'finished', + postTurnVisibleState: createVisibleState(), + events: [ + { + eventType: 'forfeit', + actor: 'opponent', + }, + ], + }; + + const payloadBackedView = createPvpTurnResultView(state, payload); + const sessionOnlyView = createPvpTurnResultView(state); + + assert.ok(payloadBackedView); + assert.equal(payloadBackedView.source, 'payload'); + assert.equal(payloadBackedView.summary.nextPhaseLabel, '배틀 종료'); + assert.equal(payloadBackedView.summary.statusLabel, '전투 종료'); + assert.equal(payloadBackedView.summary.terminalResultLabel, '승리 · 종료 사유 forfeit'); + assert.deepEqual(payloadBackedView.logs.map((entry) => entry.message), ['상대 플레이어가 항복']); + assert.deepEqual(payloadBackedView.logs.map((entry) => entry.emphasis), ['positive']); + + assert.ok(sessionOnlyView); + assert.equal(sessionOnlyView.source, 'session'); + assert.equal(sessionOnlyView.turn, 9); + assert.equal(sessionOnlyView.hasEventLog, false); + assert.deepEqual(sessionOnlyView.logs, []); + assert.equal(sessionOnlyView.summary.terminalResultLabel, '승리 · 종료 사유 forfeit'); + assert.equal(sessionOnlyView.summary.nextPhase, 'finished'); + }); + + it('returns null for null or insufficient inputs', () => { + const emptyState = createSessionState(); + + assert.equal(createPvpTurnResultViewFromPayload(null), null); + assert.equal(createPvpTurnResultView(null), null); + assert.equal(createPvpTurnResultView(emptyState), null); + }); +}); diff --git a/test/pvp-websocket-client.test.ts b/test/pvp-websocket-client.test.ts new file mode 100644 index 00000000..8845d1d9 --- /dev/null +++ b/test/pvp-websocket-client.test.ts @@ -0,0 +1,368 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { ViewerVisibleState } from '../src/server/battle/index.js'; +import { + createPvpWebSocketClient, + createPvpWebSocketUrl, + type PvpWebSocketCloseEvent, + type PvpWebSocketErrorEvent, + type PvpWebSocketLike, + type PvpWebSocketMessageEvent, +} from '../src/pvp/index.js'; + +const BASE_VISIBLE_STATE: ViewerVisibleState = { + self: { + active: { + slot: 1, + speciesId: '001', + nickname: 'Bulba', + levelActual: 55, + levelEffective: 52, + hp: 120, + hpMax: 120, + status: null, + fainted: false, + moves: [ + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + { slot: 2, id: 'growl', disabled: false, currentPp: 40 }, + ], + }, + bench: [ + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: false }, + ], + }, + opponent: { + active: { + slot: 1, + speciesId: '133', + nickname: 'Eevee', + levelActual: 54, + levelEffective: 52, + hp: 110, + hpMax: 110, + status: null, + fainted: false, + }, + benchCount: 2, + }, +}; + +function cloneVisibleState(): ViewerVisibleState { + return { + self: { + active: { ...BASE_VISIBLE_STATE.self.active }, + bench: BASE_VISIBLE_STATE.self.bench.map((entry) => ({ ...entry })), + }, + opponent: { + active: { ...BASE_VISIBLE_STATE.opponent.active }, + benchCount: BASE_VISIBLE_STATE.opponent.benchCount, + }, + }; +} + +function makeSnapshot(seq = 1) { + return { + type: 'room.snapshot', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + roomStatus: 'in_progress', + battleStatus: 'awaiting_actions', + generation: 'gen4', + rulesetKey: 'tkm-friendly-gen4-v1', + yourSeat: 'host', + turn: 3, + visibleState: cloneVisibleState(), + pendingRequest: { + kind: 'choose_move_or_switch', + deadlineMs: 30_000, + commandSubmitted: false, + }, + }, + } as const; +} + +function makeActionRequest(seq = 2) { + return { + type: 'battle.request_action', + roomId: 'room_000001', + battleId: 'battle_000001', + seq, + sentAt: `2026-04-11T10:00:0${seq}.000Z`, + payload: { + turn: 3, + phase: 'awaiting_actions', + requestId: 'req-turn-3', + deadlineMs: 25_000, + request: { + kind: 'choose_move_or_switch', + activePokemon: { ...cloneVisibleState().self.active }, + availableMoves: [ + { slot: 1, id: 'tackle', disabled: false, currentPp: 35 }, + { slot: 2, id: 'growl', disabled: false, currentPp: 40 }, + ], + availableSwitches: [ + { slot: 2, speciesId: '004', nickname: 'Charmy', fainted: false }, + { slot: 3, speciesId: '007', nickname: 'Squirt', fainted: false }, + ], + }, + }, + } as const; +} + +class FakeSocket implements PvpWebSocketLike { + onopen: (() => void) | null = null; + + onmessage: ((event: PvpWebSocketMessageEvent) => void) | null = null; + + onclose: ((event: PvpWebSocketCloseEvent) => void) | null = null; + + onerror: ((event: PvpWebSocketErrorEvent) => void) | null = null; + + readonly sent: string[] = []; + + readonly closes: Array<{ code?: number; reason?: string }> = []; + + send(data: string): void { + this.sent.push(data); + } + + close(code?: number, reason?: string): void { + this.closes.push({ code, reason }); + } + + emitOpen(): void { + this.onopen?.(); + } + + emitMessage(data: string): void { + this.onmessage?.({ data }); + } + + emitClose(event: PvpWebSocketCloseEvent = {}): void { + this.onclose?.(event); + } + + emitError(event: PvpWebSocketErrorEvent = {}): void { + this.onerror?.(event); + } +} + +function createClock(start = '2026-04-11T10:00:00.000Z') { + let current = new Date(start).getTime(); + + return { + now: () => new Date(current), + tick(ms = 1_000) { + current += ms; + }, + }; +} + +describe('pvp websocket client', () => { + it('builds websocket URLs from http origins', () => { + assert.equal( + createPvpWebSocketUrl('https://pvp.example.com', 'room_123', 'token_456'), + 'wss://pvp.example.com/ws/pvp?roomId=room_123&token=token_456', + ); + assert.equal( + createPvpWebSocketUrl('http://localhost:4317/custom/ws', 'room_123', 'token_456'), + 'ws://localhost:4317/custom/ws?roomId=room_123&token=token_456', + ); + }); + + it('connects, subscribes, and hydrates battle state from room snapshots', () => { + const clock = createClock(); + const sockets: FakeSocket[] = []; + const client = createPvpWebSocketClient({ + serverUrl: 'https://pvp.example.com', + roomId: 'room_000001', + token: 'auth-token', + now: clock.now, + createSocket(url) { + assert.equal(url, 'wss://pvp.example.com/ws/pvp?roomId=room_000001&token=auth-token'); + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + }); + + const observedStatuses: string[] = []; + const unsubscribe = client.subscribe((state) => { + observedStatuses.push(state.transportStatus); + }); + + const connectingState = client.connect(); + assert.equal(connectingState.transportStatus, 'connecting'); + assert.equal(connectingState.connectCount, 1); + + sockets[0].emitOpen(); + clock.tick(); + sockets[0].emitMessage(JSON.stringify(makeSnapshot())); + + const state = client.getState(); + assert.equal(state.transportStatus, 'connected'); + assert.equal(state.lastConnectedAt, '2026-04-11T10:00:00.000Z'); + assert.equal(state.protocol.session.roomId, 'room_000001'); + assert.equal(state.protocol.session.battleId, 'battle_000001'); + assert.equal(state.protocol.session.pendingRequest?.kind, 'choose_move_or_switch'); + assert.equal(state.lastInboundRawMessage, JSON.stringify(makeSnapshot())); + assert.deepEqual(observedStatuses, ['idle', 'connecting', 'connected', 'connected']); + + unsubscribe(); + }); + + it('replies to ws.ping with ws.pong and records outbound payloads', () => { + const clock = createClock(); + const socket = new FakeSocket(); + const client = createPvpWebSocketClient({ + serverUrl: 'wss://pvp.example.com/ws/pvp', + roomId: 'room_000001', + token: 'auth-token', + now: clock.now, + createSocket() { + return socket; + }, + }); + + client.connect(); + socket.emitOpen(); + clock.tick(250); + socket.emitMessage(JSON.stringify({ type: 'ws.ping', sentAt: '2026-04-11T10:05:00.000Z' })); + + assert.equal(socket.sent.length, 1); + assert.deepEqual(JSON.parse(socket.sent[0]), { + type: 'ws.pong', + sentAt: '2026-04-11T10:00:00.250Z', + }); + + const state = client.getState(); + assert.equal(state.protocol.lastPingAt, '2026-04-11T10:05:00.000Z'); + assert.equal(state.protocol.lastPongSentAt, '2026-04-11T10:00:00.250Z'); + assert.equal(state.lastOutboundRawMessage, socket.sent[0]); + }); + + it('serializes battle commands through the websocket after an action request', () => { + const clock = createClock(); + const socket = new FakeSocket(); + const client = createPvpWebSocketClient({ + serverUrl: 'wss://pvp.example.com/ws/pvp', + roomId: 'room_000001', + token: 'auth-token', + now: clock.now, + createSocket() { + return socket; + }, + }); + + client.connect(); + socket.emitOpen(); + socket.emitMessage(JSON.stringify(makeSnapshot())); + socket.emitMessage(JSON.stringify(makeActionRequest())); + clock.tick(2_000); + + const result = client.sendBattleCommand({ + clientCommandId: 'cmd-1', + sentAt: clock.now().toISOString(), + command: { + type: 'choose_move', + moveSlot: 1, + }, + }); + + assert.equal(socket.sent.length, 1); + assert.equal(result.serialized, socket.sent[0]); + assert.deepEqual(JSON.parse(socket.sent[0]), { + type: 'battle.command', + roomId: 'room_000001', + battleId: 'battle_000001', + seq: 1, + sentAt: '2026-04-11T10:00:02.000Z', + payload: { + clientCommandId: 'cmd-1', + turn: 3, + phase: 'awaiting_actions', + command: { + type: 'choose_move', + moveSlot: 1, + }, + }, + }); + + const state = client.getState(); + assert.equal(state.protocol.session.pendingCommand?.clientCommandId, 'cmd-1'); + assert.equal(state.protocol.session.pendingCommand?.status, 'created'); + assert.equal(state.lastOutboundRawMessage, socket.sent[0]); + }); + + it('records parse failures as transport errors without mutating the battle session', () => { + const clock = createClock(); + const socket = new FakeSocket(); + const client = createPvpWebSocketClient({ + serverUrl: 'wss://pvp.example.com/ws/pvp', + roomId: 'room_000001', + token: 'auth-token', + now: clock.now, + createSocket() { + return socket; + }, + }); + + client.connect(); + socket.emitOpen(); + socket.emitMessage(JSON.stringify(makeSnapshot())); + const before = client.getState(); + + socket.emitMessage('{bad json'); + + const after = client.getState(); + assert.equal(after.transportStatus, 'error'); + assert.equal(after.lastTransportError?.code, 'PVP_CLIENT_MESSAGE_PARSE_ERROR'); + assert.equal(after.protocol.session.roomId, before.protocol.session.roomId); + assert.equal(after.protocol.session.battleId, before.protocol.session.battleId); + assert.equal(after.protocol.lastTransportMessageType, before.protocol.lastTransportMessageType); + }); + + it('records socket close metadata and supports reconnecting with a new socket', () => { + const clock = createClock(); + const sockets: FakeSocket[] = []; + const client = createPvpWebSocketClient({ + serverUrl: 'https://pvp.example.com', + roomId: 'room_000001', + token: 'auth-token', + now: clock.now, + createSocket() { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + }); + + client.connect(); + sockets[0].emitOpen(); + clock.tick(5_000); + sockets[0].emitClose({ code: 4002, reason: 'PVP_WS_HEARTBEAT_TIMEOUT', wasClean: false }); + + let state = client.getState(); + assert.equal(state.transportStatus, 'closed'); + assert.deepEqual(state.lastClose, { + code: 4002, + reason: 'PVP_WS_HEARTBEAT_TIMEOUT', + wasClean: false, + at: '2026-04-11T10:00:05.000Z', + }); + + const reconnecting = client.reconnect(); + assert.equal(reconnecting.transportStatus, 'reconnecting'); + assert.equal(reconnecting.connectCount, 2); + sockets[1].emitOpen(); + + state = client.getState(); + assert.equal(state.transportStatus, 'connected'); + assert.equal(sockets.length, 2); + }); +}); diff --git a/test/pvp-ws-gateway.test.ts b/test/pvp-ws-gateway.test.ts new file mode 100644 index 00000000..902bb28c --- /dev/null +++ b/test/pvp-ws-gateway.test.ts @@ -0,0 +1,487 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; + +import { initLocale } from '../src/i18n/index.js'; +import type { MoveData, PokemonData } from '../src/core/types.js'; +import { + BattleSessionService, + type BattleCommandEnvelope, + type BattleDataResolver, + type PvpGeneration, +} from '../src/server/battle/index.js'; +import { + InMemoryPartySnapshotRepository, + PartyRegistrationService, + type GrowthProofInput, + type OnlinePartyMemberInput, +} from '../src/server/parties/index.js'; +import { InMemoryRoomRepository, RoomService } from '../src/server/rooms/index.js'; +import { PvpWsServer, type PvpWsOutboundEnvelope } from '../src/server/ws/index.js'; + +initLocale('ko'); + +const SPECIES_DATA: Record = { + '001': { + id: 1, + name: 'Bulbasaur', + types: ['grass'], + stage: 1, + line: ['Bulbasaur'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 80, attack: 85, defense: 80, speed: 90, sp_attack: 95, sp_defense: 85 }, + catch_rate: 45, + }, + '004': { + id: 4, + name: 'Charmander', + types: ['fire'], + stage: 1, + line: ['Charmander'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 78, attack: 84, defense: 72, speed: 88, sp_attack: 100, sp_defense: 78 }, + catch_rate: 45, + }, + '007': { + id: 7, + name: 'Squirtle', + types: ['water'], + stage: 1, + line: ['Squirtle'], + evolves_at: 16, + unlock: 'starter', + exp_group: 'medium_slow', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 79, attack: 83, defense: 100, speed: 60, sp_attack: 85, sp_defense: 105 }, + catch_rate: 45, + }, + '025': { + id: 25, + name: 'Pikachu', + types: ['electric'], + stage: 1, + line: ['Pikachu'], + evolves_at: null, + unlock: 'starter', + exp_group: 'medium_fast', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 70, attack: 60, defense: 55, speed: 110, sp_attack: 70, sp_defense: 60 }, + catch_rate: 190, + }, + '039': { + id: 39, + name: 'Jigglypuff', + types: ['normal'], + stage: 1, + line: ['Jigglypuff'], + evolves_at: null, + unlock: 'wild', + exp_group: 'fast', + rarity: 'uncommon', + region: 'kanto', + base_stats: { hp: 135, attack: 65, defense: 45, speed: 20, sp_attack: 65, sp_defense: 50 }, + catch_rate: 170, + }, + '052': { + id: 52, + name: 'Meowth', + types: ['normal'], + stage: 1, + line: ['Meowth'], + evolves_at: 28, + unlock: 'wild', + exp_group: 'medium_fast', + rarity: 'common', + region: 'kanto', + base_stats: { hp: 60, attack: 70, defense: 55, speed: 110, sp_attack: 45, sp_defense: 65 }, + catch_rate: 255, + }, +}; + +const MOVE_DATA: Record = { + 'host-fast': { + id: 101, + name: 'host-fast', + nameKo: '호스트 속공', + nameEn: 'Host Fast', + type: 'normal', + category: 'physical', + power: 55, + accuracy: 100, + pp: 20, + }, + 'host-chip': { + id: 102, + name: 'host-chip', + nameKo: '호스트 견제', + nameEn: 'Host Chip', + type: 'grass', + category: 'special', + power: 35, + accuracy: 100, + pp: 25, + }, + 'guest-fast': { + id: 201, + name: 'guest-fast', + nameKo: '게스트 속공', + nameEn: 'Guest Fast', + type: 'normal', + category: 'physical', + power: 50, + accuracy: 100, + pp: 20, + }, + 'guest-chip': { + id: 202, + name: 'guest-chip', + nameKo: '게스트 견제', + nameEn: 'Guest Chip', + type: 'fire', + category: 'special', + power: 30, + accuracy: 100, + pp: 25, + }, +}; + +const RESOLVER: BattleDataResolver = { + resolveSpecies(_generation, speciesId) { + return SPECIES_DATA[speciesId] ?? SPECIES_DATA[speciesId.padStart(3, '0')]; + }, + resolveMove(_generation, moveId) { + return MOVE_DATA[moveId]; + }, +}; + +class FakeTransport { + readonly messages: PvpWsOutboundEnvelope[] = []; + + readonly closes: Array<{ code: number; reason: string }> = []; + + send(message: PvpWsOutboundEnvelope): void { + this.messages.push(message); + } + + close(code: number, reason: string): void { + this.closes.push({ code, reason }); + } +} + +function makeMember(slot: number, speciesId: string, levelActual: number, moves: string[]): OnlinePartyMemberInput { + return { + slot, + pokemonInstanceId: `pkm-${slot}`, + speciesId, + nickname: `P-${slot}`, + levelActual, + moves, + }; +} + +function makeGrowthProof(members: OnlinePartyMemberInput[]): GrowthProofInput { + return { + proofVersion: 'v1', + capturedAt: '2026-04-11T09:00:00Z', + sourceSaveId: 'save_main', + sourceSaveRevision: 101, + cheatFlags: { + hasCheatHistory: false, + flags: [], + }, + memberProofs: members.map((member) => ({ + slot: member.slot, + pokemonInstanceId: member.pokemonInstanceId, + speciesId: member.speciesId, + levelActual: member.levelActual, + movesHash: `sha256:moves-${member.slot}`, + stateHash: `sha256:state-${member.slot}`, + })), + }; +} + +function createClock() { + let current = Date.UTC(2026, 3, 11, 8, 0, 0); + + return { + now: () => new Date(current), + advance(ms: number) { + current += ms; + }, + }; +} + +function createEnvironment() { + const clock = createClock(); + const partyRepository = new InMemoryPartySnapshotRepository(); + const roomRepository = new InMemoryRoomRepository(); + const partyService = new PartyRegistrationService({ repository: partyRepository }); + let roomCodeIndex = 0; + let battleIdIndex = 0; + let battleSeedIndex = 0; + const roomCodes = ['A7KQ2M', 'B8TR4N']; + const roomService = new RoomService({ + repository: roomRepository, + partyService, + now: clock.now, + roomCodeGenerator: () => roomCodes[roomCodeIndex++] ?? 'Z9YX8W', + battleSeedGenerator: () => `bseed_${++battleSeedIndex}`, + roomTtlMs: 15 * 60 * 1000, + }); + const battleSessionService = new BattleSessionService({ + dataResolver: RESOLVER, + now: clock.now, + battleIdGenerator: () => `battle_${String(++battleIdIndex).padStart(6, '0')}`, + }); + + const registerParty = (playerId: string, generation: PvpGeneration = 'gen1') => { + const leadMoves = + playerId === 'host-user' + ? ['host-fast', 'host-chip', 'guest-fast', 'guest-chip'] + : ['guest-fast', 'guest-chip', 'host-fast', 'host-chip']; + const members: OnlinePartyMemberInput[] = [ + makeMember(1, playerId === 'host-user' ? '001' : '004', 52, leadMoves), + makeMember(2, '007', 48, ['host-chip', 'guest-chip', 'host-fast', 'guest-fast']), + makeMember(3, '025', 40, ['guest-fast', 'host-fast', 'guest-chip', 'host-chip']), + makeMember(4, '039', 35, ['guest-chip', 'host-chip', 'guest-fast', 'host-fast']), + makeMember(5, '052', 30, ['host-fast', 'guest-fast', 'host-chip', 'guest-chip']), + makeMember(6, playerId === 'host-user' ? '004' : '001', 25, ['guest-chip', 'guest-fast', 'host-chip', 'host-fast']), + ]; + + return partyService.registerActiveParty({ + playerId, + generation, + sourceStateHash: `sha256:${playerId}:state`, + sourceConfigHash: `sha256:${playerId}:config`, + clientBuild: 'tokenmon-cli/0.120.0', + members, + growthProof: makeGrowthProof(members), + }).party; + }; + + const hostParty = registerParty('host-user'); + const guestParty = registerParty('guest-user'); + const room = roomService.createRoom({ + playerId: 'host-user', + generation: 'gen1', + visibility: 'private_friend', + }); + const joinedRoom = roomService.joinRoom({ + playerId: 'guest-user', + roomId: room.room.roomId, + roomCode: room.room.roomCode, + generation: 'gen1', + }); + + const server = new PvpWsServer({ + authenticate(token) { + if (token === 'host-token') { + return { userId: 'host-user' }; + } + if (token === 'guest-token') { + return { userId: 'guest-user' }; + } + return null; + }, + now: clock.now, + roomRepository, + battleSessionService, + loadPartySnapshot(snapshotId) { + return [hostParty, guestParty].find((party) => party.snapshotId === snapshotId); + }, + }); + + return { clock, roomRepository, room: joinedRoom, server }; +} + +function getBattleId(messages: PvpWsOutboundEnvelope[]): string { + const snapshot = messages.find((message) => message.type === 'room.snapshot'); + assert.ok(snapshot, 'room.snapshot message missing'); + return snapshot.battleId; +} + +function buildChooseMoveCommand(input: { + roomId: string; + battleId: string; + clientCommandId: string; + turn: number; + moveSlot: number; +}): BattleCommandEnvelope { + return { + type: 'battle.command', + roomId: input.roomId, + battleId: input.battleId, + seq: 1, + sentAt: '2026-04-11T08:00:00.000Z', + payload: { + clientCommandId: input.clientCommandId, + turn: input.turn, + phase: 'awaiting_actions', + command: { + type: 'choose_move', + moveSlot: input.moveSlot, + }, + }, + }; +} + +describe('PvpWsServer', () => { + beforeEach(() => { + initLocale('ko'); + }); + + it('양 플레이어 연결 후 battle command를 서버 권한으로 처리하고 좌석별 이벤트만 푸시한다', () => { + const { server, room } = createEnvironment(); + const hostTransport = new FakeTransport(); + const guestTransport = new FakeTransport(); + + const hostConnection = server.connectClient({ + roomId: room.room.roomId, + token: 'host-token', + connectionId: 'conn-host', + transport: hostTransport, + }); + + assert.equal(hostConnection.seat, 'host'); + assert.equal(hostConnection.battleId, null); + assert.deepEqual(hostTransport.messages, []); + + const guestConnection = server.connectClient({ + roomId: room.room.roomId, + token: 'guest-token', + connectionId: 'conn-guest', + transport: guestTransport, + }); + + assert.equal(guestConnection.seat, 'guest'); + assert.deepEqual(hostTransport.messages.map((message) => message.type), ['room.snapshot', 'battle.request_action']); + assert.equal(guestTransport.messages.map((message) => message.type).join(','), 'room.snapshot,battle.request_action'); + + const battleId = getBattleId(hostTransport.messages); + assert.equal(guestConnection.battleId, battleId); + + server.receiveMessage( + 'conn-host', + buildChooseMoveCommand({ + roomId: room.room.roomId, + battleId, + clientCommandId: 'host-cmd-1', + turn: 1, + moveSlot: 1, + }), + ); + + assert.equal(hostTransport.messages.at(-1)?.type, 'battle.command_accepted'); + assert.equal(guestTransport.messages.at(-1)?.type, 'battle.request_action'); + + server.receiveMessage( + 'conn-guest', + buildChooseMoveCommand({ + roomId: room.room.roomId, + battleId, + clientCommandId: 'guest-cmd-1', + turn: 1, + moveSlot: 1, + }), + ); + + const hostResolved = hostTransport.messages.findLast((message) => message.type === 'battle.turn_resolved'); + const guestResolved = guestTransport.messages.findLast((message) => message.type === 'battle.turn_resolved'); + assert.ok(hostResolved && hostResolved.type === 'battle.turn_resolved'); + assert.ok(guestResolved && guestResolved.type === 'battle.turn_resolved'); + assert.equal(hostResolved.payload.events[0]?.eventType, 'move_used'); + assert.equal(hostResolved.payload.events[0]?.actor, 'self'); + assert.equal(guestResolved.payload.events[0]?.eventType, 'move_used'); + assert.equal(guestResolved.payload.events[0]?.actor, 'opponent'); + assert.equal(hostTransport.messages.at(-1)?.type, 'battle.request_action'); + assert.equal(guestTransport.messages.at(-1)?.type, 'battle.request_action'); + }); + + it('heartbeat timeout이 발생하면 stale connection을 끊고 room presence를 disconnected로 저장한다', () => { + const { server, roomRepository, room, clock } = createEnvironment(); + const hostTransport = new FakeTransport(); + const guestTransport = new FakeTransport(); + + server.connectClient({ + roomId: room.room.roomId, + token: 'host-token', + connectionId: 'conn-host', + transport: hostTransport, + }); + server.connectClient({ + roomId: room.room.roomId, + token: 'guest-token', + connectionId: 'conn-guest', + transport: guestTransport, + }); + + clock.advance(10_000); + server.sweepHeartbeats(); + + assert.equal(hostTransport.messages.at(-1)?.type, 'ws.ping'); + assert.equal(guestTransport.messages.at(-1)?.type, 'ws.ping'); + + server.receiveMessage('conn-host', { + type: 'ws.pong', + sentAt: '2026-04-11T08:00:10.000Z', + }); + + clock.advance(15_000); + server.sweepHeartbeats(); + + assert.deepEqual(guestTransport.closes.at(-1), { + code: 4002, + reason: 'PVP_WS_HEARTBEAT_TIMEOUT', + }); + + const persistedRoom = roomRepository.getRoom(room.room.roomId); + assert.equal(persistedRoom?.host.presence, 'connected'); + assert.equal(persistedRoom?.guest?.presence, 'disconnected'); + }); + + it('같은 좌석의 중복 연결은 새 연결을 거부하고 기존 연결을 유지한다', () => { + const { server, room } = createEnvironment(); + const originalHostTransport = new FakeTransport(); + const duplicateHostTransport = new FakeTransport(); + const guestTransport = new FakeTransport(); + + server.connectClient({ + roomId: room.room.roomId, + token: 'host-token', + connectionId: 'conn-host-1', + transport: originalHostTransport, + }); + server.connectClient({ + roomId: room.room.roomId, + token: 'guest-token', + connectionId: 'conn-guest', + transport: guestTransport, + }); + + assert.throws( + () => + server.connectClient({ + roomId: room.room.roomId, + token: 'host-token', + connectionId: 'conn-host-2', + transport: duplicateHostTransport, + }), + /PVP_WS_DUPLICATE_CONNECTION/, + ); + + assert.deepEqual(duplicateHostTransport.closes.at(-1), { + code: 4001, + reason: 'PVP_WS_DUPLICATE_CONNECTION', + }); + assert.equal(originalHostTransport.closes.length, 0); + }); +});