From 141b417eb40300566db18756103939e653767fa5 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 15:28:26 +0900 Subject: [PATCH 01/30] Capture the agreed PvP multiplayer design before implementation This commit freezes the current PvP documentation set in an isolated worktree so later refinement can be reviewed as a second pass instead of being mixed with the initial architecture capture. Constraint: Source workspace contains unrelated dirty changes, so docs had to be isolated in a dedicated worktree Constraint: The agreed direction is server-authoritative PvP with local-grown party registration and anti-cheat priorities Rejected: Edit directly on the main workspace branch | unrelated local changes would pollute the documentation diff Confidence: high Scope-risk: narrow Reversibility: clean Directive: Treat this commit as the baseline snapshot before structural refinements; keep later docs-only changes reviewable Tested: Markdown inventory check, relative link check, root README reachability check, npm test Not-tested: Human editorial review for wording/style consistency across every section --- docs/pvp/README.md | 142 ++++ docs/pvp/game-design/README.md | 18 + docs/pvp/game-design/battle-format.md | 96 +++ docs/pvp/game-design/generation-rules.md | 126 ++++ .../progression-and-party-registration.md | 118 ++++ .../pvp/game-design/special-pokemon-policy.md | 107 +++ docs/pvp/implementation/README.md | 24 + docs/pvp/implementation/prd.md | 164 +++++ .../implementation/server-package-layout.md | 510 ++++++++++++++ docs/pvp/implementation/todo-breakdown.md | 186 +++++ docs/pvp/roadmap/README.md | 15 + docs/pvp/roadmap/rollout-plan.md | 128 ++++ docs/pvp/security/README.md | 15 + docs/pvp/security/anti-cheat.md | 103 +++ docs/pvp/server/README.md | 27 + docs/pvp/server/api-contract.md | 234 +++++++ docs/pvp/server/architecture.md | 122 ++++ docs/pvp/server/battle-flow.md | 139 ++++ docs/pvp/server/data-model.md | 222 ++++++ docs/pvp/server/database-schema.md | 488 ++++++++++++++ .../pvp/server/party-registration-contract.md | 633 +++++++++++++++++ .../realtime-battle-session-contract.md | 638 ++++++++++++++++++ docs/pvp/server/room-and-match-contract.md | 488 ++++++++++++++ 23 files changed, 4743 insertions(+) create mode 100644 docs/pvp/README.md create mode 100644 docs/pvp/game-design/README.md create mode 100644 docs/pvp/game-design/battle-format.md create mode 100644 docs/pvp/game-design/generation-rules.md create mode 100644 docs/pvp/game-design/progression-and-party-registration.md create mode 100644 docs/pvp/game-design/special-pokemon-policy.md create mode 100644 docs/pvp/implementation/README.md create mode 100644 docs/pvp/implementation/prd.md create mode 100644 docs/pvp/implementation/server-package-layout.md create mode 100644 docs/pvp/implementation/todo-breakdown.md create mode 100644 docs/pvp/roadmap/README.md create mode 100644 docs/pvp/roadmap/rollout-plan.md create mode 100644 docs/pvp/security/README.md create mode 100644 docs/pvp/security/anti-cheat.md create mode 100644 docs/pvp/server/README.md create mode 100644 docs/pvp/server/api-contract.md create mode 100644 docs/pvp/server/architecture.md create mode 100644 docs/pvp/server/battle-flow.md create mode 100644 docs/pvp/server/data-model.md create mode 100644 docs/pvp/server/database-schema.md create mode 100644 docs/pvp/server/party-registration-contract.md create mode 100644 docs/pvp/server/realtime-battle-session-contract.md create mode 100644 docs/pvp/server/room-and-match-contract.md diff --git a/docs/pvp/README.md b/docs/pvp/README.md new file mode 100644 index 00000000..e57baa3c --- /dev/null +++ b/docs/pvp/README.md @@ -0,0 +1,142 @@ +# 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. [서버 데이터 모델](./server/data-model.md) +7. [서버 DB 스키마 초안](./server/database-schema.md) +8. [HTTP / WebSocket API 계약 초안](./server/api-contract.md) +9. [온라인 파티 등록 상세 계약](./server/party-registration-contract.md) +10. [친구전 룸 / 매치 성립 상세 계약](./server/room-and-match-contract.md) +11. [실시간 배틀 세션 상세 계약](./server/realtime-battle-session-contract.md) +12. [실시간 배틀 흐름](./server/battle-flow.md) +13. [치트 대응 / 보안 정책](./security/anti-cheat.md) +14. [구현 로드맵](./roadmap/rollout-plan.md) +15. [서버 패키지 / 모듈 구조 제안](./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) + - [데이터 모델](./server/data-model.md) + - [DB 스키마 초안](./server/database-schema.md) + - [API 계약](./server/api-contract.md) + - [온라인 파티 등록 상세 계약](./server/party-registration-contract.md) + - [친구전 룸 / 매치 성립 상세 계약](./server/room-and-match-contract.md) + - [실시간 배틀 세션 상세 계약](./server/realtime-battle-session-contract.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)는 왜 서버 권한 구조가 필요한지 설명한다. +- [데이터 모델](./server/data-model.md)은 엔티티 개념도를 설명하고, [서버 DB 스키마 초안](./server/database-schema.md)은 이를 컬럼/제약 수준으로 구체화한다. +- [API 계약](./server/api-contract.md)은 서버 아키텍처와 스키마를 전체 입출력 표면으로 정리한 문서다. +- [온라인 파티 등록 상세 계약](./server/party-registration-contract.md)은 그중 Phase 1 등록/조회 계약을 필드 단위까지 세밀하게 내린 문서다. +- [친구전 룸 / 매치 성립 상세 계약](./server/room-and-match-contract.md)은 Phase 2의 room binding / presence / battle freeze 계약을 세밀하게 내린 문서다. +- [실시간 배틀 세션 상세 계약](./server/realtime-battle-session-contract.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) +- 등록 계약 상세: [온라인 파티 등록 상세 계약](./server/party-registration-contract.md) +- 룸 계약 상세: [친구전 룸 / 매치 성립 상세 계약](./server/room-and-match-contract.md) +- 실시간 세션 계약 상세: [실시간 배틀 세션 상세 계약](./server/realtime-battle-session-contract.md) +- 코드 구조 제안: [서버 패키지 / 모듈 구조 제안](./implementation/server-package-layout.md) +- 데이터 중심 상세: [서버 데이터 모델](./server/data-model.md) +- DB 구체안: [서버 DB 스키마 초안](./server/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..c14cde1d --- /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..d6e18400 --- /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/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/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..80445bb2 --- /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/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/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..64f1c22c --- /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..862848b1 --- /dev/null +++ b/docs/pvp/implementation/README.md @@ -0,0 +1,24 @@ +# PvP 구현 계획 문서 + +상위 문서: [PvP 문서 인덱스](../README.md) + +이 섹션은 Tokénmon 온라인 PvP 설계를 **실제 구현 가능한 작업 단위**로 바꾸는 문서 모음이다. + +## 포함 문서 + +1. [PvP 초기 구현 PRD](./prd.md) +2. [PvP 작업 분해 / TODO](./todo-breakdown.md) +3. [서버 패키지 / 모듈 구조 제안](./server-package-layout.md) + +## 읽는 순서 + +- 먼저 [PvP 초기 구현 PRD](./prd.md)에서 목표, 비목표, 성공 기준을 본다. +- 다음 [PvP 작업 분해 / TODO](./todo-breakdown.md)에서 실제 개발 순서와 작업 단위를 본다. +- 그 다음 [서버 패키지 / 모듈 구조 제안](./server-package-layout.md)으로 실제 `src/server` 경계를 어떻게 나눌지 본다. + + +## 구현 시 먼저 볼 상세 계약 + +- Phase 1 등록 작업 전에는 [온라인 파티 등록 상세 계약](../server/party-registration-contract.md)을 먼저 읽는다. +- Phase 2 룸 작업 전에는 [친구전 룸 / 매치 성립 상세 계약](../server/room-and-match-contract.md)을 먼저 읽는다. +- Phase 3 배틀 세션 작업 전에는 [실시간 배틀 세션 상세 계약](../server/realtime-battle-session-contract.md)을 먼저 읽는다. diff --git a/docs/pvp/implementation/prd.md b/docs/pvp/implementation/prd.md new file mode 100644 index 00000000..413b3a00 --- /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..a4a6b914 --- /dev/null +++ b/docs/pvp/implementation/server-package-layout.md @@ -0,0 +1,510 @@ +# PvP 서버 패키지 / 모듈 구조 제안 + +상위 문서: [PvP 구현 계획 문서](./README.md) +관련 문서: [서버 아키텍처](../server/architecture.md), [서버 DB 스키마 초안](../server/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/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..c280b6f4 --- /dev/null +++ b/docs/pvp/implementation/todo-breakdown.md @@ -0,0 +1,186 @@ +# PvP 작업 분해 / TODO + +상위 문서: [PvP 구현 계획 문서](./README.md) +기반 설계 문서: [PvP 초기 구현 PRD](./prd.md), [구현 로드맵](../roadmap/rollout-plan.md), [서버 DB 스키마 초안](../server/database-schema.md), [서버 패키지 / 모듈 구조 제안](./server-package-layout.md) + +## 목적 + +이 문서는 초기 PvP를 실제 코드 작업 단위로 쪼갠다. +순서는 **규칙 확정 → 등록 → 룸 → 서버 권한 배틀 → UX → 안정화** 기준으로 잡는다. + +--- + +## Phase 0. 규칙/정책 고정 + +### TODO +- [ ] generation별 ruleset 키 포맷 결정 +- [ ] restricted 시드 리스트를 generation별 정책 데이터로 정리 +- [ ] 레벨 압축 공식을 문서에서 구현 규칙으로 확정 +- [ ] 치트 오염 상태의 판정 기준 확정 + +### 산출물 +- 정책 파일 또는 정책 로더 초안 +- ruleset identifier 규칙 +- restricted seed data + +--- + +## Phase 1. 온라인 파티 등록 + +상세 기준 문서: [온라인 파티 등록 상세 계약](../server/party-registration-contract.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/room-and-match-contract.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/realtime-battle-session-contract.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 분리 저장 규칙 추가 + +### 클라이언트 작업 +- [ ] action request 렌더링 +- [ ] move/switch/replacement 입력 UX 구현 +- [ ] command accepted 상태 반영 +- [ ] turn resolved 이벤트 렌더링 + +### 주의사항 +- 클라이언트는 결과 계산 금지 +- 상대 백라인 정보 누출 금지 +- public/private payload 분리 강제 + +--- + +## Phase 4. 재접속 / 안정화 + +### 서버 작업 +- [ ] room snapshot 재구성 로직 추가 +- [ ] last visible state 복구 로직 추가 +- [ ] reconnect 시 타이머 재계산 로직 추가 +- [ ] 중복 명령 제출 방지 처리 추가 + +### 클라이언트 작업 +- [ ] 끊김 후 재접속 UX 추가 +- [ ] 이미 제출한 명령 표시 처리 +- [ ] 진행 중 턴 상태 복원 처리 + +--- + +## 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..15665e41 --- /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..5218cbad --- /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..a57e75bc --- /dev/null +++ b/docs/pvp/server/README.md @@ -0,0 +1,27 @@ +# PvP 서버 설계 문서 + +상위 문서: [PvP 문서 인덱스](../README.md) + +이 섹션은 Tokénmon 온라인 PvP의 **서버 구조, 저장 모델, 통신 계약, 실시간 흐름**을 정의한다. + +## 포함 문서 + +1. [서버 아키텍처](./architecture.md) +2. [데이터 모델](./data-model.md) +3. [DB 스키마 초안](./database-schema.md) +4. [API 계약](./api-contract.md) +5. [온라인 파티 등록 상세 계약](./party-registration-contract.md) +6. [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) +7. [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md) +8. [실시간 배틀 흐름](./battle-flow.md) + +## 권장 읽기 순서 + +- 먼저 [서버 아키텍처](./architecture.md)로 왜 서버 권한 구조가 필요한지 본다. +- 다음 [데이터 모델](./data-model.md)로 엔티티 관계를 본다. +- 그 다음 [DB 스키마 초안](./database-schema.md)으로 실제 컬럼/제약 수준까지 내린다. +- 이후 [API 계약](./api-contract.md)으로 전체 표면을 훑는다. +- Phase 1 구현 전에는 [온라인 파티 등록 상세 계약](./party-registration-contract.md)으로 등록/조회 계약을 필드 단위까지 본다. +- Phase 2 구현 전에는 [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md)으로 room freeze / presence / match binding 계약을 본다. +- Phase 3 구현 전에는 [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md)으로 WebSocket 명령/이벤트 계약을 본다. +- 마지막으로 [실시간 배틀 흐름](./battle-flow.md)으로 플레이 시퀀스를 다시 연결해서 본다. diff --git a/docs/pvp/server/api-contract.md b/docs/pvp/server/api-contract.md new file mode 100644 index 00000000..1ffd9d8e --- /dev/null +++ b/docs/pvp/server/api-contract.md @@ -0,0 +1,234 @@ +# PvP HTTP / WebSocket API 계약 초안 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [서버 아키텍처](./architecture.md), [서버 데이터 모델](./data-model.md), [온라인 파티 등록 상세 계약](./party-registration-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.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` + +상세 계약은 [온라인 파티 등록 상세 계약](./party-registration-contract.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` + +상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.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` + +상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md)을 따른다. + +요청 예시: + +```json +{ + "roomCode": "A7KQ2M", + "generation": "gen4" +} +``` + +### 6. 룸 상태 조회 + +`GET /api/pvp/rooms/{roomId}` + +상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md)을 따른다. + +재접속 시 초기 동기화에 사용한다. + +--- + +## WebSocket 연결 + +### 연결 + +`GET /ws/pvp?roomId=&token=` + +상세 계약은 [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.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 +- 클라이언트는 명령만 보냄 +- 서버는 플레이어별 가시 정보만 내려줌 + +## 다음 문서 + +- [온라인 파티 등록 상세 계약](./party-registration-contract.md) +- [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) +- [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.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..be49c6ac --- /dev/null +++ b/docs/pvp/server/architecture.md @@ -0,0 +1,122 @@ +# PvP 서버 아키텍처 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [서버 데이터 모델](./data-model.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.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) +- 클라이언트는 입력 장치 + 렌더러 +- 숨은 정보는 서버에서 플레이어별로 분리 투영 +- 룸/파티/배틀 로그를 데이터 모델로 명확히 분리 + +## 다음 문서 + +- [서버 데이터 모델](./data-model.md) +- [API 계약](./api-contract.md) +- [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) +- [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.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..09cc9332 --- /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), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md) + +## 목표 + +이 문서는 실제 플레이어 경험 기준으로, 온라인 친선 PvP가 어떤 순서로 진행되는지 정의한다. +필드별 payload와 오류 코드는 [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) 및 [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.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) +- [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) +- [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md) +- [치트 대응 정책](../security/anti-cheat.md) diff --git a/docs/pvp/server/data-model.md b/docs/pvp/server/data-model.md new file mode 100644 index 00000000..12037b77 --- /dev/null +++ b/docs/pvp/server/data-model.md @@ -0,0 +1,222 @@ +# PvP 서버 데이터 모델 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [서버 아키텍처](./architecture.md), [DB 스키마 초안](./database-schema.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.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) +- [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) +- [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md) +- [실시간 배틀 흐름](./battle-flow.md) diff --git a/docs/pvp/server/database-schema.md b/docs/pvp/server/database-schema.md new file mode 100644 index 00000000..a1f3c80d --- /dev/null +++ b/docs/pvp/server/database-schema.md @@ -0,0 +1,488 @@ +# PvP 서버 DB 스키마 초안 + +상위 문서: [PvP 문서 인덱스](../README.md) +관련 문서: [서버 데이터 모델](./data-model.md), [서버 아키텍처](./architecture.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.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/docs/pvp/server/party-registration-contract.md b/docs/pvp/server/party-registration-contract.md new file mode 100644 index 00000000..8736e4f0 --- /dev/null +++ b/docs/pvp/server/party-registration-contract.md @@ -0,0 +1,633 @@ +# 온라인 파티 등록 상세 계약 + +상위 문서: [PvP 서버 설계 문서](./README.md) +관련 문서: [API 계약 초안](./api-contract.md), [서버 데이터 모델](./data-model.md), [DB 스키마 초안](./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-contract.md) +- [실시간 배틀 흐름](./battle-flow.md) +- [PvP 작업 분해 / TODO](../implementation/todo-breakdown.md) diff --git a/docs/pvp/server/realtime-battle-session-contract.md b/docs/pvp/server/realtime-battle-session-contract.md new file mode 100644 index 00000000..ee6cd236 --- /dev/null +++ b/docs/pvp/server/realtime-battle-session-contract.md @@ -0,0 +1,638 @@ +# 실시간 배틀 세션 상세 계약 + +상위 문서: [PvP 서버 설계 문서](./README.md) +관련 문서: [API 계약 초안](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.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/room-and-match-contract.md b/docs/pvp/server/room-and-match-contract.md new file mode 100644 index 00000000..676fa625 --- /dev/null +++ b/docs/pvp/server/room-and-match-contract.md @@ -0,0 +1,488 @@ +# 친구전 룸 / 매치 성립 상세 계약 + +상위 문서: [PvP 서버 설계 문서](./README.md) +관련 문서: [API 계약 초안](./api-contract.md), [온라인 파티 등록 상세 계약](./party-registration-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.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-contract.md) +- [실시간 배틀 흐름](./battle-flow.md) +- [치트 대응 정책](../security/anti-cheat.md) From 87ebdc1485e03efe8310a72696ff0a9500224335 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 15:33:05 +0900 Subject: [PATCH 02/30] Make the PvP server docs navigable by splitting storage and contract concerns The first pass captured the agreed multiplayer PvP design, but the server section still forced readers to jump through several large mixed-purpose documents. This follow-up separates persistence-facing material from runtime contract material, adds local index pages for both buckets, and rewires the top-level reading path so implementation work can enter the docs tree in smaller, purpose-specific steps. Constraint: The documentation had to stay inside docs/pvp and remain fully reachable from the root README Rejected: Keep every server document flat under docs/pvp/server | navigation cost stayed high as contracts and schema detail grew Confidence: high Scope-risk: narrow Reversibility: clean Directive: Future server docs should be filed under storage or contracts instead of returning to a flat server directory Tested: Markdown relative-link scan across docs/pvp, root README reachability traversal (25/25), npm test (1102 pass / 0 fail) Not-tested: Human editorial review for duplicated phrasing across the new index pages --- docs/pvp/README.md | 51 ++++++++++--------- docs/pvp/game-design/generation-rules.md | 4 +- .../progression-and-party-registration.md | 4 +- docs/pvp/implementation/README.md | 6 +-- .../implementation/server-package-layout.md | 4 +- docs/pvp/implementation/todo-breakdown.md | 8 +-- docs/pvp/server/README.md | 48 ++++++++++------- docs/pvp/server/api-contract.md | 18 +++---- docs/pvp/server/architecture.md | 8 +-- docs/pvp/server/battle-flow.md | 8 +-- docs/pvp/server/contracts/README.md | 24 +++++++++ .../party-registration.md} | 12 ++--- .../realtime-battle-session.md} | 10 ++-- .../room-and-match.md} | 10 ++-- docs/pvp/server/storage/README.md | 22 ++++++++ docs/pvp/server/{ => storage}/data-model.md | 12 ++--- .../server/{ => storage}/database-schema.md | 8 +-- 17 files changed, 159 insertions(+), 98 deletions(-) create mode 100644 docs/pvp/server/contracts/README.md rename docs/pvp/server/{party-registration-contract.md => contracts/party-registration.md} (97%) rename docs/pvp/server/{realtime-battle-session-contract.md => contracts/realtime-battle-session.md} (97%) rename docs/pvp/server/{room-and-match-contract.md => contracts/room-and-match.md} (96%) create mode 100644 docs/pvp/server/storage/README.md rename docs/pvp/server/{ => storage}/data-model.md (86%) rename docs/pvp/server/{ => storage}/database-schema.md (97%) diff --git a/docs/pvp/README.md b/docs/pvp/README.md index e57baa3c..4c2b5e13 100644 --- a/docs/pvp/README.md +++ b/docs/pvp/README.md @@ -38,16 +38,13 @@ 3. [특수 포켓몬 정책](./game-design/special-pokemon-policy.md) 4. [세대별 ruleset 구조](./game-design/generation-rules.md) 5. [서버 아키텍처](./server/architecture.md) -6. [서버 데이터 모델](./server/data-model.md) -7. [서버 DB 스키마 초안](./server/database-schema.md) -8. [HTTP / WebSocket API 계약 초안](./server/api-contract.md) -9. [온라인 파티 등록 상세 계약](./server/party-registration-contract.md) -10. [친구전 룸 / 매치 성립 상세 계약](./server/room-and-match-contract.md) -11. [실시간 배틀 세션 상세 계약](./server/realtime-battle-session-contract.md) -12. [실시간 배틀 흐름](./server/battle-flow.md) -13. [치트 대응 / 보안 정책](./security/anti-cheat.md) -14. [구현 로드맵](./roadmap/rollout-plan.md) -15. [서버 패키지 / 모듈 구조 제안](./implementation/server-package-layout.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) --- @@ -63,12 +60,14 @@ ### 2. 서버 설계 - [서버 설계 인덱스](./server/README.md) - [서버 아키텍처](./server/architecture.md) - - [데이터 모델](./server/data-model.md) - - [DB 스키마 초안](./server/database-schema.md) + - [Storage 인덱스](./server/storage/README.md) + - [데이터 모델](./server/storage/data-model.md) + - [DB 스키마 초안](./server/storage/database-schema.md) - [API 계약](./server/api-contract.md) - - [온라인 파티 등록 상세 계약](./server/party-registration-contract.md) - - [친구전 룸 / 매치 성립 상세 계약](./server/room-and-match-contract.md) - - [실시간 배틀 세션 상세 계약](./server/realtime-battle-session-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. 보안 / 운영 @@ -91,11 +90,13 @@ - [성장 및 파티 등록](./game-design/progression-and-party-registration.md)은 로컬 성장과 온라인 사용을 연결하는 문서다. - [특수 포켓몬 정책](./game-design/special-pokemon-policy.md)과 [세대별 ruleset 설계](./game-design/generation-rules.md)는 배틀 포맷의 세부 제약을 정의한다. - [서버 아키텍처](./server/architecture.md)는 왜 서버 권한 구조가 필요한지 설명한다. -- [데이터 모델](./server/data-model.md)은 엔티티 개념도를 설명하고, [서버 DB 스키마 초안](./server/database-schema.md)은 이를 컬럼/제약 수준으로 구체화한다. +- [Storage 인덱스](./server/storage/README.md)는 저장 계층 하위 묶음의 출발점이다. +- [데이터 모델](./server/storage/data-model.md)은 엔티티 개념도를 설명하고, [서버 DB 스키마 초안](./server/storage/database-schema.md)은 이를 컬럼/제약 수준으로 구체화한다. - [API 계약](./server/api-contract.md)은 서버 아키텍처와 스키마를 전체 입출력 표면으로 정리한 문서다. -- [온라인 파티 등록 상세 계약](./server/party-registration-contract.md)은 그중 Phase 1 등록/조회 계약을 필드 단위까지 세밀하게 내린 문서다. -- [친구전 룸 / 매치 성립 상세 계약](./server/room-and-match-contract.md)은 Phase 2의 room binding / presence / battle freeze 계약을 세밀하게 내린 문서다. -- [실시간 배틀 세션 상세 계약](./server/realtime-battle-session-contract.md)은 Phase 3의 WebSocket 명령/이벤트 계약을 세밀하게 내린 문서다. +- [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)은 이 설계를 어떤 순서로 구현할지 정리한 문서다. @@ -133,10 +134,12 @@ - 구현 우선순위: [구현 로드맵](./roadmap/rollout-plan.md) - 구현 PRD: [PvP 초기 구현 PRD](./implementation/prd.md) - 구현 작업 분해: [PvP 작업 분해 / TODO](./implementation/todo-breakdown.md) -- 등록 계약 상세: [온라인 파티 등록 상세 계약](./server/party-registration-contract.md) -- 룸 계약 상세: [친구전 룸 / 매치 성립 상세 계약](./server/room-and-match-contract.md) -- 실시간 세션 계약 상세: [실시간 배틀 세션 상세 계약](./server/realtime-battle-session-contract.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/data-model.md) -- DB 구체안: [서버 DB 스키마 초안](./server/database-schema.md) +- 데이터 중심 상세: [서버 데이터 모델](./server/storage/data-model.md) +- DB 구체안: [서버 DB 스키마 초안](./server/storage/database-schema.md) - 보안 중심 상세: [치트 대응 정책](./security/anti-cheat.md) diff --git a/docs/pvp/game-design/generation-rules.md b/docs/pvp/game-design/generation-rules.md index d6e18400..7d326c27 100644 --- a/docs/pvp/game-design/generation-rules.md +++ b/docs/pvp/game-design/generation-rules.md @@ -1,7 +1,7 @@ # 세대별 ruleset 설계 상위 문서: [PvP 문서 인덱스](../README.md) -관련 문서: [성장 및 파티 등록](./progression-and-party-registration.md), [특수 포켓몬 정책](./special-pokemon-policy.md), [서버 데이터 모델](../server/data-model.md) +관련 문서: [성장 및 파티 등록](./progression-and-party-registration.md), [특수 포켓몬 정책](./special-pokemon-policy.md), [서버 데이터 모델](../server/storage/data-model.md) ## 왜 세대별 ruleset이 필요한가 @@ -122,5 +122,5 @@ Tokénmon은 이미 세대별 데이터와 진행 구조를 갖고 있다. ## 다음 문서 -- [서버 데이터 모델](../server/data-model.md) +- [서버 데이터 모델](../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 index 80445bb2..57359205 100644 --- a/docs/pvp/game-design/progression-and-party-registration.md +++ b/docs/pvp/game-design/progression-and-party-registration.md @@ -1,7 +1,7 @@ # 성장 및 파티 등록 구조 상위 문서: [PvP 문서 인덱스](../README.md) -관련 문서: [배틀 포맷](./battle-format.md), [세대별 ruleset 설계](./generation-rules.md), [치트 대응 정책](../security/anti-cheat.md), [서버 데이터 모델](../server/data-model.md) +관련 문서: [배틀 포맷](./battle-format.md), [세대별 ruleset 설계](./generation-rules.md), [치트 대응 정책](../security/anti-cheat.md), [서버 데이터 모델](../server/storage/data-model.md) ## 핵심 원칙 @@ -114,5 +114,5 @@ Tokénmon의 온라인 PvP는 **로컬 스토리/성장을 유지**하면서도, ## 다음에 읽을 문서 - [세대별 ruleset 설계](./generation-rules.md) -- [서버 데이터 모델](../server/data-model.md) +- [서버 데이터 모델](../server/storage/data-model.md) - [치트 대응 정책](../security/anti-cheat.md) diff --git a/docs/pvp/implementation/README.md b/docs/pvp/implementation/README.md index 862848b1..4210952e 100644 --- a/docs/pvp/implementation/README.md +++ b/docs/pvp/implementation/README.md @@ -19,6 +19,6 @@ ## 구현 시 먼저 볼 상세 계약 -- Phase 1 등록 작업 전에는 [온라인 파티 등록 상세 계약](../server/party-registration-contract.md)을 먼저 읽는다. -- Phase 2 룸 작업 전에는 [친구전 룸 / 매치 성립 상세 계약](../server/room-and-match-contract.md)을 먼저 읽는다. -- Phase 3 배틀 세션 작업 전에는 [실시간 배틀 세션 상세 계약](../server/realtime-battle-session-contract.md)을 먼저 읽는다. +- Phase 1 등록 작업 전에는 [온라인 파티 등록 상세 계약](../server/contracts/party-registration.md)을 먼저 읽는다. +- Phase 2 룸 작업 전에는 [친구전 룸 / 매치 성립 상세 계약](../server/contracts/room-and-match.md)을 먼저 읽는다. +- Phase 3 배틀 세션 작업 전에는 [실시간 배틀 세션 상세 계약](../server/contracts/realtime-battle-session.md)을 먼저 읽는다. diff --git a/docs/pvp/implementation/server-package-layout.md b/docs/pvp/implementation/server-package-layout.md index a4a6b914..a40464c3 100644 --- a/docs/pvp/implementation/server-package-layout.md +++ b/docs/pvp/implementation/server-package-layout.md @@ -1,7 +1,7 @@ # PvP 서버 패키지 / 모듈 구조 제안 상위 문서: [PvP 구현 계획 문서](./README.md) -관련 문서: [서버 아키텍처](../server/architecture.md), [서버 DB 스키마 초안](../server/database-schema.md), [HTTP / WebSocket API 계약 초안](../server/api-contract.md), [PvP 작업 분해 / TODO](./todo-breakdown.md) +관련 문서: [서버 아키텍처](../server/architecture.md), [서버 DB 스키마 초안](../server/storage/database-schema.md), [HTTP / WebSocket API 계약 초안](../server/api-contract.md), [PvP 작업 분해 / TODO](./todo-breakdown.md) ## 목적 @@ -506,5 +506,5 @@ test/ ## 다음 문서 - [PvP 작업 분해 / TODO](./todo-breakdown.md) -- [서버 DB 스키마 초안](../server/database-schema.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 index c280b6f4..66770072 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -1,7 +1,7 @@ # PvP 작업 분해 / TODO 상위 문서: [PvP 구현 계획 문서](./README.md) -기반 설계 문서: [PvP 초기 구현 PRD](./prd.md), [구현 로드맵](../roadmap/rollout-plan.md), [서버 DB 스키마 초안](../server/database-schema.md), [서버 패키지 / 모듈 구조 제안](./server-package-layout.md) +기반 설계 문서: [PvP 초기 구현 PRD](./prd.md), [구현 로드맵](../roadmap/rollout-plan.md), [서버 DB 스키마 초안](../server/storage/database-schema.md), [서버 패키지 / 모듈 구조 제안](./server-package-layout.md) ## 목적 @@ -27,7 +27,7 @@ ## Phase 1. 온라인 파티 등록 -상세 기준 문서: [온라인 파티 등록 상세 계약](../server/party-registration-contract.md) +상세 기준 문서: [온라인 파티 등록 상세 계약](../server/contracts/party-registration.md) ### 서버 작업 - [ ] generation ruleset 조회 endpoint 추가 @@ -61,7 +61,7 @@ ## Phase 2. 친구전 룸 시스템 -상세 기준 문서: [친구전 룸 / 매치 성립 상세 계약](../server/room-and-match-contract.md) +상세 기준 문서: [친구전 룸 / 매치 성립 상세 계약](../server/contracts/room-and-match.md) ### 서버 작업 - [ ] 룸 생성 endpoint 추가 @@ -91,7 +91,7 @@ ## Phase 3. 서버 권한 배틀 코어 -상세 기준 문서: [실시간 배틀 세션 상세 계약](../server/realtime-battle-session-contract.md) +상세 기준 문서: [실시간 배틀 세션 상세 계약](../server/contracts/realtime-battle-session.md) ### 서버 작업 - [ ] WebSocket 연결 진입점 추가 diff --git a/docs/pvp/server/README.md b/docs/pvp/server/README.md index a57e75bc..62b2156c 100644 --- a/docs/pvp/server/README.md +++ b/docs/pvp/server/README.md @@ -2,26 +2,38 @@ 상위 문서: [PvP 문서 인덱스](../README.md) -이 섹션은 Tokénmon 온라인 PvP의 **서버 구조, 저장 모델, 통신 계약, 실시간 흐름**을 정의한다. +이 섹션은 Tokénmon 온라인 PvP의 **서버 구조, 저장 모델, 통신 계약, 실시간 흐름**을 정의한다. +이번 정리에서는 서버 문서를 `개요`, `storage`, `contracts`로 한 단계 더 쪼개서 읽기 순서를 분명하게 만들었다. -## 포함 문서 +## 문서 구조 -1. [서버 아키텍처](./architecture.md) -2. [데이터 모델](./data-model.md) -3. [DB 스키마 초안](./database-schema.md) -4. [API 계약](./api-contract.md) -5. [온라인 파티 등록 상세 계약](./party-registration-contract.md) -6. [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) -7. [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md) -8. [실시간 배틀 흐름](./battle-flow.md) +### 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) ## 권장 읽기 순서 -- 먼저 [서버 아키텍처](./architecture.md)로 왜 서버 권한 구조가 필요한지 본다. -- 다음 [데이터 모델](./data-model.md)로 엔티티 관계를 본다. -- 그 다음 [DB 스키마 초안](./database-schema.md)으로 실제 컬럼/제약 수준까지 내린다. -- 이후 [API 계약](./api-contract.md)으로 전체 표면을 훑는다. -- Phase 1 구현 전에는 [온라인 파티 등록 상세 계약](./party-registration-contract.md)으로 등록/조회 계약을 필드 단위까지 본다. -- Phase 2 구현 전에는 [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md)으로 room freeze / presence / match binding 계약을 본다. -- Phase 3 구현 전에는 [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md)으로 WebSocket 명령/이벤트 계약을 본다. -- 마지막으로 [실시간 배틀 흐름](./battle-flow.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 index 1ffd9d8e..3fb4935a 100644 --- a/docs/pvp/server/api-contract.md +++ b/docs/pvp/server/api-contract.md @@ -1,7 +1,7 @@ # PvP HTTP / WebSocket API 계약 초안 상위 문서: [PvP 문서 인덱스](../README.md) -관련 문서: [서버 아키텍처](./architecture.md), [서버 데이터 모델](./data-model.md), [온라인 파티 등록 상세 계약](./party-registration-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md), [실시간 배틀 흐름](./battle-flow.md), [치트 대응 정책](../security/anti-cheat.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) ## 목표 @@ -40,7 +40,7 @@ `PUT /api/pvp/parties/{generation}/active` -상세 계약은 [온라인 파티 등록 상세 계약](./party-registration-contract.md)을 따른다. +상세 계약은 [온라인 파티 등록 상세 계약](./contracts/party-registration.md)을 따른다. 요청 예시: @@ -72,7 +72,7 @@ `POST /api/pvp/rooms` -상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md)을 따른다. +상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md)을 따른다. 요청 예시: @@ -98,7 +98,7 @@ `POST /api/pvp/rooms/{roomId}/join` -상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md)을 따른다. +상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md)을 따른다. 요청 예시: @@ -113,7 +113,7 @@ `GET /api/pvp/rooms/{roomId}` -상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md)을 따른다. +상세 계약은 [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md)을 따른다. 재접속 시 초기 동기화에 사용한다. @@ -125,7 +125,7 @@ `GET /ws/pvp?roomId=&token=` -상세 계약은 [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md)을 따른다. +상세 계약은 [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md)을 따른다. 클라이언트는 룸 입장 완료 후 WebSocket을 연결한다. @@ -227,8 +227,8 @@ HTTP와 WebSocket 모두 다음 종류의 에러를 분리하는 것이 좋다. ## 다음 문서 -- [온라인 파티 등록 상세 계약](./party-registration-contract.md) -- [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) -- [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md) +- [온라인 파티 등록 상세 계약](./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 index be49c6ac..e18a557e 100644 --- a/docs/pvp/server/architecture.md +++ b/docs/pvp/server/architecture.md @@ -1,7 +1,7 @@ # PvP 서버 아키텍처 상위 문서: [PvP 문서 인덱스](../README.md) -관련 문서: [서버 데이터 모델](./data-model.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md), [실시간 배틀 흐름](./battle-flow.md), [치트 대응 정책](../security/anti-cheat.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) ## 핵심 결정 @@ -115,8 +115,8 @@ ## 다음 문서 -- [서버 데이터 모델](./data-model.md) +- [서버 데이터 모델](./storage/data-model.md) - [API 계약](./api-contract.md) -- [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) -- [실시간 배틀 세션 상세 계약](./realtime-battle-session-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 index 09cc9332..9a926257 100644 --- a/docs/pvp/server/battle-flow.md +++ b/docs/pvp/server/battle-flow.md @@ -1,12 +1,12 @@ # 실시간 배틀 흐름 상위 문서: [PvP 문서 인덱스](../README.md) -관련 문서: [배틀 포맷](../game-design/battle-format.md), [서버 아키텍처](./architecture.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md) +관련 문서: [배틀 포맷](../game-design/battle-format.md), [서버 아키텍처](./architecture.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md), [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md) ## 목표 이 문서는 실제 플레이어 경험 기준으로, 온라인 친선 PvP가 어떤 순서로 진행되는지 정의한다. -필드별 payload와 오류 코드는 [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) 및 [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md)을 따른다. +필드별 payload와 오류 코드는 [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md) 및 [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md)을 따른다. ## 전체 흐름 @@ -134,6 +134,6 @@ waiting_for_opponent ## 다음 문서 - [API 계약](./api-contract.md) -- [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) -- [실시간 배틀 세션 상세 계약](./realtime-battle-session-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..a3d8e354 --- /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/party-registration-contract.md b/docs/pvp/server/contracts/party-registration.md similarity index 97% rename from docs/pvp/server/party-registration-contract.md rename to docs/pvp/server/contracts/party-registration.md index 8736e4f0..621b5f78 100644 --- a/docs/pvp/server/party-registration-contract.md +++ b/docs/pvp/server/contracts/party-registration.md @@ -1,7 +1,7 @@ # 온라인 파티 등록 상세 계약 -상위 문서: [PvP 서버 설계 문서](./README.md) -관련 문서: [API 계약 초안](./api-contract.md), [서버 데이터 모델](./data-model.md), [DB 스키마 초안](./database-schema.md), [성장 및 파티 등록](../game-design/progression-and-party-registration.md), [치트 대응 정책](../security/anti-cheat.md) +상위 문서: [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) ## 목적 @@ -627,7 +627,7 @@ ## 다음 문서 -- [API 계약 초안](./api-contract.md) -- [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) -- [실시간 배틀 흐름](./battle-flow.md) -- [PvP 작업 분해 / TODO](../implementation/todo-breakdown.md) +- [API 계약 초안](../api-contract.md) +- [친구전 룸 / 매치 성립 상세 계약](./room-and-match.md) +- [실시간 배틀 흐름](../battle-flow.md) +- [PvP 작업 분해 / TODO](../../implementation/todo-breakdown.md) diff --git a/docs/pvp/server/realtime-battle-session-contract.md b/docs/pvp/server/contracts/realtime-battle-session.md similarity index 97% rename from docs/pvp/server/realtime-battle-session-contract.md rename to docs/pvp/server/contracts/realtime-battle-session.md index ee6cd236..91372983 100644 --- a/docs/pvp/server/realtime-battle-session-contract.md +++ b/docs/pvp/server/contracts/realtime-battle-session.md @@ -1,7 +1,7 @@ # 실시간 배틀 세션 상세 계약 -상위 문서: [PvP 서버 설계 문서](./README.md) -관련 문서: [API 계약 초안](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 흐름](./battle-flow.md), [치트 대응 정책](../security/anti-cheat.md) +상위 문서: [PvP 서버 Contracts 문서](./README.md) +관련 문서: [API 계약 초안](../api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match.md), [실시간 배틀 흐름](../battle-flow.md), [치트 대응 정책](../../security/anti-cheat.md) ## 목적 @@ -633,6 +633,6 @@ awaiting_presence ## 다음 문서 -- [실시간 배틀 흐름](./battle-flow.md) -- [치트 대응 정책](../security/anti-cheat.md) -- [서버 아키텍처](./architecture.md) +- [실시간 배틀 흐름](../battle-flow.md) +- [치트 대응 정책](../../security/anti-cheat.md) +- [서버 아키텍처](../architecture.md) diff --git a/docs/pvp/server/room-and-match-contract.md b/docs/pvp/server/contracts/room-and-match.md similarity index 96% rename from docs/pvp/server/room-and-match-contract.md rename to docs/pvp/server/contracts/room-and-match.md index 676fa625..44cc4649 100644 --- a/docs/pvp/server/room-and-match-contract.md +++ b/docs/pvp/server/contracts/room-and-match.md @@ -1,7 +1,7 @@ # 친구전 룸 / 매치 성립 상세 계약 -상위 문서: [PvP 서버 설계 문서](./README.md) -관련 문서: [API 계약 초안](./api-contract.md), [온라인 파티 등록 상세 계약](./party-registration-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md), [실시간 배틀 흐름](./battle-flow.md), [치트 대응 정책](../security/anti-cheat.md) +상위 문서: [PvP 서버 Contracts 문서](./README.md) +관련 문서: [API 계약 초안](../api-contract.md), [온라인 파티 등록 상세 계약](./party-registration.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session.md), [실시간 배틀 흐름](../battle-flow.md), [치트 대응 정책](../../security/anti-cheat.md) ## 목적 @@ -483,6 +483,6 @@ Body 없음. ## 다음 문서 -- [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md) -- [실시간 배틀 흐름](./battle-flow.md) -- [치트 대응 정책](../security/anti-cheat.md) +- [실시간 배틀 세션 상세 계약](./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..3c5237ff --- /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/data-model.md b/docs/pvp/server/storage/data-model.md similarity index 86% rename from docs/pvp/server/data-model.md rename to docs/pvp/server/storage/data-model.md index 12037b77..52e65014 100644 --- a/docs/pvp/server/data-model.md +++ b/docs/pvp/server/storage/data-model.md @@ -1,7 +1,7 @@ # PvP 서버 데이터 모델 -상위 문서: [PvP 문서 인덱스](../README.md) -관련 문서: [서버 아키텍처](./architecture.md), [DB 스키마 초안](./database-schema.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md), [성장 및 파티 등록](../game-design/progression-and-party-registration.md), [세대별 ruleset 설계](../game-design/generation-rules.md) +상위 문서: [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) ## 목표 @@ -216,7 +216,7 @@ generation_rulesets ## 다음 문서 - [DB 스키마 초안](./database-schema.md) -- [API 계약](./api-contract.md) -- [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md) -- [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md) -- [실시간 배틀 흐름](./battle-flow.md) +- [API 계약](../api-contract.md) +- [친구전 룸 / 매치 성립 상세 계약](../contracts/room-and-match.md) +- [실시간 배틀 세션 상세 계약](../contracts/realtime-battle-session.md) +- [실시간 배틀 흐름](../battle-flow.md) diff --git a/docs/pvp/server/database-schema.md b/docs/pvp/server/storage/database-schema.md similarity index 97% rename from docs/pvp/server/database-schema.md rename to docs/pvp/server/storage/database-schema.md index a1f3c80d..d5389dfe 100644 --- a/docs/pvp/server/database-schema.md +++ b/docs/pvp/server/storage/database-schema.md @@ -1,7 +1,7 @@ # PvP 서버 DB 스키마 초안 -상위 문서: [PvP 문서 인덱스](../README.md) -관련 문서: [서버 데이터 모델](./data-model.md), [서버 아키텍처](./architecture.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match-contract.md), [실시간 배틀 세션 상세 계약](./realtime-battle-session-contract.md), [치트 대응 정책](../security/anti-cheat.md), [PvP 작업 분해 / TODO](../implementation/todo-breakdown.md) +상위 문서: [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) ## 목적 @@ -484,5 +484,5 @@ generation_rulesets ## 다음 문서 - [서버 데이터 모델](./data-model.md) -- [HTTP / WebSocket API 계약 초안](./api-contract.md) -- [PvP 작업 분해 / TODO](../implementation/todo-breakdown.md) +- [HTTP / WebSocket API 계약 초안](../api-contract.md) +- [PvP 작업 분해 / TODO](../../implementation/todo-breakdown.md) From 5895ef03bbe891cf1143699e2d4e078ec7f25d34 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 15:35:29 +0900 Subject: [PATCH 03/30] Keep the PvP docs verification-clean after the structural split A final verification pass found that the documentation commits still left trailing whitespace in several Markdown files. This follow-up normalizes the formatting without changing meaning so that repo-level cleanliness checks pass and the documentation branch can be handed off without avoidable noise. Constraint: The follow-up had to be content-preserving and limited to docs/pvp formatting cleanup Rejected: Leave whitespace issues in place | verification would continue to report a dirty diff despite the docs being structurally complete Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep running git diff --check on docs-only branches before calling the documentation pass finished Tested: git diff --check, Markdown relative-link scan across docs/pvp, root README reachability traversal (25/25) Not-tested: npm test rerun after whitespace-only edits (no runtime-affecting changes) --- docs/pvp/README.md | 6 ++--- docs/pvp/game-design/battle-format.md | 8 +++---- docs/pvp/game-design/generation-rules.md | 6 ++--- .../progression-and-party-registration.md | 4 ++-- .../pvp/game-design/special-pokemon-policy.md | 6 ++--- docs/pvp/implementation/prd.md | 4 ++-- .../implementation/server-package-layout.md | 10 ++++---- docs/pvp/implementation/todo-breakdown.md | 4 ++-- docs/pvp/roadmap/rollout-plan.md | 6 ++--- docs/pvp/security/anti-cheat.md | 8 +++---- docs/pvp/server/README.md | 2 +- docs/pvp/server/api-contract.md | 6 ++--- docs/pvp/server/architecture.md | 2 +- docs/pvp/server/battle-flow.md | 4 ++-- docs/pvp/server/contracts/README.md | 2 +- .../server/contracts/party-registration.md | 10 ++++---- .../contracts/realtime-battle-session.md | 10 ++++---- docs/pvp/server/contracts/room-and-match.md | 8 +++---- docs/pvp/server/storage/README.md | 2 +- docs/pvp/server/storage/data-model.md | 2 +- docs/pvp/server/storage/database-schema.md | 24 +++++++++---------- 21 files changed, 67 insertions(+), 67 deletions(-) diff --git a/docs/pvp/README.md b/docs/pvp/README.md index 4c2b5e13..8b7b2432 100644 --- a/docs/pvp/README.md +++ b/docs/pvp/README.md @@ -1,12 +1,12 @@ # Tokénmon PvP 문서 인덱스 -상태: Draft v1 -범위: 온라인 친선 PvP 초기 설계 +상태: Draft v1 +범위: 온라인 친선 PvP 초기 설계 기준 방향: **세대별 온라인 파티 등록 + 서버 권한 전투 + 인게임 감성 6v6 싱글** ## 문서 목적 -이 문서 세트는 Tokénmon의 온라인 PvP를 실제 구현 가능한 단위로 구조화한 상위/하위 설계 문서 모음이다. +이 문서 세트는 Tokénmon의 온라인 PvP를 실제 구현 가능한 단위로 구조화한 상위/하위 설계 문서 모음이다. 핵심 목표는 다음 세 가지다. 1. **전투 결과 위조를 막는다.** diff --git a/docs/pvp/game-design/battle-format.md b/docs/pvp/game-design/battle-format.md index c14cde1d..f026a3da 100644 --- a/docs/pvp/game-design/battle-format.md +++ b/docs/pvp/game-design/battle-format.md @@ -1,11 +1,11 @@ # PvP 배틀 포맷 -상위 문서: [PvP 문서 인덱스](../README.md) +상위 문서: [PvP 문서 인덱스](../README.md) 관련 문서: [성장 및 파티 등록](./progression-and-party-registration.md), [특수 포켓몬 정책](./special-pokemon-policy.md), [실시간 배틀 흐름](../server/battle-flow.md) ## 목표 -초기 Tokénmon PvP는 래더/대회 포맷보다 **인게임에서 갑자기 트레이너와 조우한 듯한 감성**을 우선한다. +초기 Tokénmon PvP는 래더/대회 포맷보다 **인게임에서 갑자기 트레이너와 조우한 듯한 감성**을 우선한다. 즉, 유저는 미리 출전 3마리를 고르고 공개 정보를 많이 보는 방식보다, **자신이 키운 풀 파티를 그대로 들고 와서 실시간으로 판단하는 경험**을 하게 된다. ## 핵심 포맷 @@ -24,12 +24,12 @@ ### 1. 인게임 감성을 유지한다 -공식 대회식 팀 프리뷰와 선발 선택은 경쟁적으론 좋지만, Tokénmon이 주려는 감성인 “현장에서 즉시 싸움이 걸리는 느낌”과는 거리가 있다. +공식 대회식 팀 프리뷰와 선발 선택은 경쟁적으론 좋지만, Tokénmon이 주려는 감성인 “현장에서 즉시 싸움이 걸리는 느낌”과는 거리가 있다. 초기 친선 PvP에서는 룰의 완전한 공정성보다도 **게임의 정체성 유지**가 더 중요하다. ### 2. 숨은 정보가 긴장감을 만든다 -상대 엔트리를 모르는 상태에서 전투를 시작하면, 교체 판단과 기술 선택의 가치가 커진다. +상대 엔트리를 모르는 상태에서 전투를 시작하면, 교체 판단과 기술 선택의 가치가 커진다. 이 구조는 특히 친선전에서 “예상 못 한 포켓몬이 뒤에서 나온다”는 재미를 만든다. ### 3. 현재 배틀 엔진 구조와도 잘 맞는다 diff --git a/docs/pvp/game-design/generation-rules.md b/docs/pvp/game-design/generation-rules.md index 7d326c27..28891bcd 100644 --- a/docs/pvp/game-design/generation-rules.md +++ b/docs/pvp/game-design/generation-rules.md @@ -1,11 +1,11 @@ # 세대별 ruleset 설계 -상위 문서: [PvP 문서 인덱스](../README.md) +상위 문서: [PvP 문서 인덱스](../README.md) 관련 문서: [성장 및 파티 등록](./progression-and-party-registration.md), [특수 포켓몬 정책](./special-pokemon-policy.md), [서버 데이터 모델](../server/storage/data-model.md) ## 왜 세대별 ruleset이 필요한가 -Tokénmon은 이미 세대별 데이터와 진행 구조를 갖고 있다. +Tokénmon은 이미 세대별 데이터와 진행 구조를 갖고 있다. 따라서 온라인 PvP도 하나의 통합 규칙으로 뭉개기보다, **세대별 포켓몬 풀과 ruleset을 별도로 운영**하는 것이 자연스럽다. 이 구조를 선택하면 다음이 가능하다. @@ -60,7 +60,7 @@ Tokénmon은 이미 세대별 데이터와 진행 구조를 갖고 있다. ## restricted 시드 리스트 v0 -아래 목록은 초기 밸런싱 시작점이다. +아래 목록은 초기 밸런싱 시작점이다. 확정 영구 규칙이라기보다, **첫 친선 PvP 운영을 위한 시드 목록**으로 본다. | 세대 | restricted 시드 후보 | diff --git a/docs/pvp/game-design/progression-and-party-registration.md b/docs/pvp/game-design/progression-and-party-registration.md index 57359205..9226cd27 100644 --- a/docs/pvp/game-design/progression-and-party-registration.md +++ b/docs/pvp/game-design/progression-and-party-registration.md @@ -1,6 +1,6 @@ # 성장 및 파티 등록 구조 -상위 문서: [PvP 문서 인덱스](../README.md) +상위 문서: [PvP 문서 인덱스](../README.md) 관련 문서: [배틀 포맷](./battle-format.md), [세대별 ruleset 설계](./generation-rules.md), [치트 대응 정책](../security/anti-cheat.md), [서버 데이터 모델](../server/storage/data-model.md) ## 핵심 원칙 @@ -23,7 +23,7 @@ Tokénmon의 온라인 PvP는 **로컬 스토리/성장을 유지**하면서도, - 파티 크기 기본값은 3이다. - 보유 포켓몬 상태는 종 ID 중심으로 관리되는 경향이 강하다. -즉, 현재 로컬 데이터는 “온라인용 확정 출전 파티”를 표현하기에 적합하지 않다. +즉, 현재 로컬 데이터는 “온라인용 확정 출전 파티”를 표현하기에 적합하지 않다. 따라서 온라인 PvP는 로컬 파티와 별개의 **등록 파티 개념**을 두는 것이 맞다. ## 등록 파티의 정의 diff --git a/docs/pvp/game-design/special-pokemon-policy.md b/docs/pvp/game-design/special-pokemon-policy.md index 64f1c22c..62d71516 100644 --- a/docs/pvp/game-design/special-pokemon-policy.md +++ b/docs/pvp/game-design/special-pokemon-policy.md @@ -1,6 +1,6 @@ # 전설 / 환상 / restricted 정책 -상위 문서: [PvP 문서 인덱스](../README.md) +상위 문서: [PvP 문서 인덱스](../README.md) 관련 문서: [배틀 포맷](./battle-format.md), [세대별 ruleset 설계](./generation-rules.md), [치트 대응 정책](../security/anti-cheat.md) ## 목표 @@ -27,7 +27,7 @@ Tokénmon은 전설과 환상을 아예 금지하는 방향이 아니라, **쓸 - `restricted` -이 분류는 온라인 PvP 밸런스를 위한 별도 개념이다. +이 분류는 온라인 PvP 밸런스를 위한 별도 개념이다. 즉, 어떤 포켓몬이 `legendary`이면서 `restricted`일 수도 있고, `mythical`이지만 `restricted`가 아닐 수도 있다. ## 제한 규칙 @@ -79,7 +79,7 @@ Tokénmon은 전설과 환상을 아예 금지하는 방향이 아니라, **쓸 ## Regigigas 같은 예외 -원작에서는 특정 특성 때문에 약점이 있는 포켓몬도, Tokénmon 구현 상태에 따라 그 약점이 사라질 수 있다. +원작에서는 특정 특성 때문에 약점이 있는 포켓몬도, Tokénmon 구현 상태에 따라 그 약점이 사라질 수 있다. 이 경우 원작 평가와 달리 Tokénmon에서는 restricted로 올려야 할 수 있다. 즉 restricted는 “포켓몬 본가 메타의 명성”이 아니라, **현재 Tokénmon 엔진 기준 실전 영향력**으로 결정한다. diff --git a/docs/pvp/implementation/prd.md b/docs/pvp/implementation/prd.md index 413b3a00..a774c2fc 100644 --- a/docs/pvp/implementation/prd.md +++ b/docs/pvp/implementation/prd.md @@ -1,11 +1,11 @@ # PvP 초기 구현 PRD -상위 문서: [PvP 구현 계획 문서](./README.md) +상위 문서: [PvP 구현 계획 문서](./README.md) 기반 설계 문서: [PvP 문서 인덱스](../README.md), [배틀 포맷](../game-design/battle-format.md), [서버 아키텍처](../server/architecture.md), [치트 대응 정책](../security/anti-cheat.md) ## 1. 문제 정의 -Tokénmon은 현재 로컬/스토리 중심 구조이기 때문에, 플레이어가 서로 직접 붙는 온라인 친선 PvP 경험이 없다. +Tokénmon은 현재 로컬/스토리 중심 구조이기 때문에, 플레이어가 서로 직접 붙는 온라인 친선 PvP 경험이 없다. 하지만 사용자는 다음을 동시에 원한다. 1. 로컬에서 키운 파티를 온라인에 가져오고 싶다. diff --git a/docs/pvp/implementation/server-package-layout.md b/docs/pvp/implementation/server-package-layout.md index a40464c3..8effdfe9 100644 --- a/docs/pvp/implementation/server-package-layout.md +++ b/docs/pvp/implementation/server-package-layout.md @@ -1,11 +1,11 @@ # PvP 서버 패키지 / 모듈 구조 제안 -상위 문서: [PvP 구현 계획 문서](./README.md) +상위 문서: [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 안에 어떤 디렉터리와 모듈 경계를 두는 게 좋은지**를 정리한다. +이 문서는 PvP 서버를 실제로 구현할 때 **repo 안에 어떤 디렉터리와 모듈 경계를 두는 게 좋은지**를 정리한다. 즉, 앞선 문서들이 “무슨 기능이 필요한가”를 정의했다면, 이 문서는 “그 기능을 코드베이스 어디에 어떻게 놓을 것인가”를 정리하는 문서다. 초기 목표는 다음 세 가지다. @@ -25,7 +25,7 @@ - `src/battle-tui/`: 로컬 전투 렌더링/UI - `src/hooks/`, `src/setup/`, `src/audio/`, `src/sprites/`: 부가 기능 -즉, **게임 규칙 엔진은 이미 `src/core`에 있고**, 아직 온라인 서버 전용 계층은 없다. +즉, **게임 규칙 엔진은 이미 `src/core`에 있고**, 아직 온라인 서버 전용 계층은 없다. 그래서 초기 PvP 구현은 아래 원칙으로 가는 것이 좋다. - **게임 규칙은 `src/core` 중심 재사용** @@ -302,7 +302,7 @@ DB 접근 계층. - 트랜잭션 경계 제공 - repository 인터페이스 제공 -중요한 점은 business rule을 repository 안에 과하게 넣지 않는 것이다. +중요한 점은 business rule을 repository 안에 과하게 넣지 않는 것이다. 검증/정책은 service 계층, 저장은 repository 계층으로 나눈다. --- @@ -385,7 +385,7 @@ DB 접근 계층. - `src/core/status-effects.ts` - `src/core/pokemon-data.ts` -같은 파일들은 **규칙 엔진 / 도메인 데이터 계층**으로 계속 남기고, +같은 파일들은 **규칙 엔진 / 도메인 데이터 계층**으로 계속 남기고, `src/server/battle/battle-engine-adapter.ts`가 이들을 감싸서 온라인 배틀에서 사용하도록 만드는 것이 좋다. ### 추천 어댑터 책임 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index 66770072..73196d0d 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -1,11 +1,11 @@ # PvP 작업 분해 / TODO -상위 문서: [PvP 구현 계획 문서](./README.md) +상위 문서: [PvP 구현 계획 문서](./README.md) 기반 설계 문서: [PvP 초기 구현 PRD](./prd.md), [구현 로드맵](../roadmap/rollout-plan.md), [서버 DB 스키마 초안](../server/storage/database-schema.md), [서버 패키지 / 모듈 구조 제안](./server-package-layout.md) ## 목적 -이 문서는 초기 PvP를 실제 코드 작업 단위로 쪼갠다. +이 문서는 초기 PvP를 실제 코드 작업 단위로 쪼갠다. 순서는 **규칙 확정 → 등록 → 룸 → 서버 권한 배틀 → UX → 안정화** 기준으로 잡는다. --- diff --git a/docs/pvp/roadmap/rollout-plan.md b/docs/pvp/roadmap/rollout-plan.md index 15665e41..2109464c 100644 --- a/docs/pvp/roadmap/rollout-plan.md +++ b/docs/pvp/roadmap/rollout-plan.md @@ -1,11 +1,11 @@ # PvP 구현 로드맵 -상위 문서: [PvP 문서 인덱스](../README.md) +상위 문서: [PvP 문서 인덱스](../README.md) 관련 문서: [서버 아키텍처](../server/architecture.md), [치트 대응 정책](../security/anti-cheat.md), [API 계약](../server/api-contract.md) ## 목표 -이 로드맵은 현재 설계를 실제 개발 단계로 어떻게 나눌지 정리한다. +이 로드맵은 현재 설계를 실제 개발 단계로 어떻게 나눌지 정리한다. 핵심 원칙은 **작게 시작하고, 서버 권한 구조를 먼저 굳히는 것**이다. ## 단계 0. 규칙 고정 @@ -118,7 +118,7 @@ ## 문서 사용법 -이 문서는 일정표가 아니라 **구현 분해 기준**이다. +이 문서는 일정표가 아니라 **구현 분해 기준**이다. 실제 개발에 들어갈 때는 이 로드맵을 기반으로 별도의 구현 계획 문서를 만들고, 각 단계별로 테스트 전략과 파일 변경 범위를 더 구체화하면 된다. ## 관련 문서 diff --git a/docs/pvp/security/anti-cheat.md b/docs/pvp/security/anti-cheat.md index 5218cbad..e11aaf10 100644 --- a/docs/pvp/security/anti-cheat.md +++ b/docs/pvp/security/anti-cheat.md @@ -1,6 +1,6 @@ # 치트 대응 정책 -상위 문서: [PvP 문서 인덱스](../README.md) +상위 문서: [PvP 문서 인덱스](../README.md) 관련 문서: [서버 아키텍처](../server/architecture.md), [성장 및 파티 등록](../game-design/progression-and-party-registration.md), [API 계약](../server/api-contract.md), [구현 로드맵](../roadmap/rollout-plan.md) ## 가장 막고 싶은 것 @@ -26,7 +26,7 @@ ### 2. 온라인은 등록 스냅샷 기반 -배틀 직전 로컬 메모리를 그대로 읽어 싸우게 하면 조작 여지가 너무 크다. +배틀 직전 로컬 메모리를 그대로 읽어 싸우게 하면 조작 여지가 너무 크다. 따라서 서버가 검증해 저장한 **온라인 파티 스냅샷**을 기준으로 싸워야 한다. ### 3. 치트 오염 상태는 온라인 불가 @@ -56,7 +56,7 @@ ## 현실적인 절충 -스토리/성장이 로컬에 있는 구조에서는 완전 무결성을 바로 달성하기 어렵다. +스토리/성장이 로컬에 있는 구조에서는 완전 무결성을 바로 달성하기 어렵다. 따라서 초기 친선 PvP의 현실적인 목표는 다음이다. - 결과 위조는 강하게 차단 @@ -91,7 +91,7 @@ ## 설계 결론 -초기 PvP의 보안은 “무조건 모든 치트를 완벽히 막는다”가 아니라, **가장 치명적인 두 위조를 먼저 막는 구조를 고정한다**가 핵심이다. +초기 PvP의 보안은 “무조건 모든 치트를 완벽히 막는다”가 아니라, **가장 치명적인 두 위조를 먼저 막는 구조를 고정한다**가 핵심이다. 그 구조의 중심은 다음 두 가지다. - 서버 권한 전투 diff --git a/docs/pvp/server/README.md b/docs/pvp/server/README.md index 62b2156c..b4eb4057 100644 --- a/docs/pvp/server/README.md +++ b/docs/pvp/server/README.md @@ -2,7 +2,7 @@ 상위 문서: [PvP 문서 인덱스](../README.md) -이 섹션은 Tokénmon 온라인 PvP의 **서버 구조, 저장 모델, 통신 계약, 실시간 흐름**을 정의한다. +이 섹션은 Tokénmon 온라인 PvP의 **서버 구조, 저장 모델, 통신 계약, 실시간 흐름**을 정의한다. 이번 정리에서는 서버 문서를 `개요`, `storage`, `contracts`로 한 단계 더 쪼개서 읽기 순서를 분명하게 만들었다. ## 문서 구조 diff --git a/docs/pvp/server/api-contract.md b/docs/pvp/server/api-contract.md index 3fb4935a..3cf168d9 100644 --- a/docs/pvp/server/api-contract.md +++ b/docs/pvp/server/api-contract.md @@ -1,11 +1,11 @@ # PvP HTTP / WebSocket API 계약 초안 -상위 문서: [PvP 문서 인덱스](../README.md) +상위 문서: [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 계약**을 정의한다. +이 문서는 초기 친선 PvP 구현을 위한 **최소 API 계약**을 정의한다. 핵심 원칙은 “REST로 준비하고, WebSocket으로 싸운다”이다. --- @@ -215,7 +215,7 @@ HTTP와 WebSocket 모두 다음 종류의 에러를 분리하는 것이 좋다. ## 숨은 정보 원칙 -상대 백라인, 상대의 비공개 상세 상태 등은 서버가 보내지 않는다. +상대 백라인, 상대의 비공개 상세 상태 등은 서버가 보내지 않는다. 따라서 `room.snapshot`과 `battle.turn_resolved`는 **플레이어별 투영 결과**여야 한다. ## 초기 API 결론 diff --git a/docs/pvp/server/architecture.md b/docs/pvp/server/architecture.md index e18a557e..27d1cc19 100644 --- a/docs/pvp/server/architecture.md +++ b/docs/pvp/server/architecture.md @@ -1,6 +1,6 @@ # PvP 서버 아키텍처 -상위 문서: [PvP 문서 인덱스](../README.md) +상위 문서: [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) ## 핵심 결정 diff --git a/docs/pvp/server/battle-flow.md b/docs/pvp/server/battle-flow.md index 9a926257..2cae8a22 100644 --- a/docs/pvp/server/battle-flow.md +++ b/docs/pvp/server/battle-flow.md @@ -1,6 +1,6 @@ # 실시간 배틀 흐름 -상위 문서: [PvP 문서 인덱스](../README.md) +상위 문서: [PvP 문서 인덱스](../README.md) 관련 문서: [배틀 포맷](../game-design/battle-format.md), [서버 아키텍처](./architecture.md), [API 계약](./api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./contracts/room-and-match.md), [실시간 배틀 세션 상세 계약](./contracts/realtime-battle-session.md) ## 목표 @@ -128,7 +128,7 @@ waiting_for_opponent ## 설계 결론 -실시간 PvP는 턴제이지만, 네트워크 구조는 **이벤트 기반 실시간 시스템**으로 봐야 한다. +실시간 PvP는 턴제이지만, 네트워크 구조는 **이벤트 기반 실시간 시스템**으로 봐야 한다. 즉, “한 턴 입력 → 서버 계산 → 이벤트 스트림 반영”이 기본 루프다. ## 다음 문서 diff --git a/docs/pvp/server/contracts/README.md b/docs/pvp/server/contracts/README.md index a3d8e354..d08d6e75 100644 --- a/docs/pvp/server/contracts/README.md +++ b/docs/pvp/server/contracts/README.md @@ -2,7 +2,7 @@ 상위 문서: [서버 설계 인덱스](../README.md) -이 섹션은 서버가 외부에 노출하는 **Phase별 상세 계약**을 모아둔 하위 묶음이다. +이 섹션은 서버가 외부에 노출하는 **Phase별 상세 계약**을 모아둔 하위 묶음이다. 즉, 실제 구현 시 “어떤 endpoint / 메시지 / 상태 전이가 허용되는가”를 필드 단위로 따라갈 때 읽는 문서들이다. ## 포함 문서 diff --git a/docs/pvp/server/contracts/party-registration.md b/docs/pvp/server/contracts/party-registration.md index 621b5f78..94149d81 100644 --- a/docs/pvp/server/contracts/party-registration.md +++ b/docs/pvp/server/contracts/party-registration.md @@ -1,11 +1,11 @@ # 온라인 파티 등록 상세 계약 -상위 문서: [PvP 서버 Contracts 문서](./README.md) +상위 문서: [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 계약**을 상세화한다. +이 문서는 초기 PvP의 **Phase 1 계약**을 상세화한다. 범위는 다음 세 가지다. 1. `GET /api/pvp/rulesets/{generation}` @@ -200,7 +200,7 @@ ### 최소 요구 의도 -초기 버전에서 이것은 “완전한 암호학적 증명”이 아니다. +초기 버전에서 이것은 “완전한 암호학적 증명”이 아니다. 대신 서버가 다음을 확인할 수 있게 해 주는 **등록 시점 일관성 메타데이터**다. 1. 어떤 저장 기준에서 읽었는가 @@ -293,7 +293,7 @@ ### 메모 -이 endpoint는 클라이언트가 파티 등록 UI를 그리기 전에 반드시 호출하는 것을 권장한다. +이 endpoint는 클라이언트가 파티 등록 UI를 그리기 전에 반드시 호출하는 것을 권장한다. 즉, 등록 계약의 기준은 **클라이언트 하드코딩이 아니라 서버 ruleset**이다. --- @@ -617,7 +617,7 @@ ## Phase 1 결론 -초기 PvP에서 중요한 것은 “내 로컬 파티를 그대로 믿고 즉석에서 싸우게 하는 것”이 아니다. +초기 PvP에서 중요한 것은 “내 로컬 파티를 그대로 믿고 즉석에서 싸우게 하는 것”이 아니다. 더 중요한 것은 다음 두 가지다. 1. **서버가 온라인에 쓸 파티를 사전에 승인한다.** diff --git a/docs/pvp/server/contracts/realtime-battle-session.md b/docs/pvp/server/contracts/realtime-battle-session.md index 91372983..f64b90cd 100644 --- a/docs/pvp/server/contracts/realtime-battle-session.md +++ b/docs/pvp/server/contracts/realtime-battle-session.md @@ -1,11 +1,11 @@ # 실시간 배틀 세션 상세 계약 -상위 문서: [PvP 서버 Contracts 문서](./README.md) +상위 문서: [PvP 서버 Contracts 문서](./README.md) 관련 문서: [API 계약 초안](../api-contract.md), [친구전 룸 / 매치 성립 상세 계약](./room-and-match.md), [실시간 배틀 흐름](../battle-flow.md), [치트 대응 정책](../../security/anti-cheat.md) ## 목적 -이 문서는 초기 PvP의 **Phase 3 계약**을 상세화한다. +이 문서는 초기 PvP의 **Phase 3 계약**을 상세화한다. 범위는 다음 두 가지다. 1. `GET /ws/pvp?roomId=&token=` 연결 규칙 @@ -30,11 +30,11 @@ ## 계약 원칙 ### 1. 클라이언트는 명령만 보내고 결과는 계산하지 않는다 -클라이언트는 `choose_move`, `choose_switch`, `choose_replacement`, `forfeit` 같은 **의도(intent)** 만 보낸다. +클라이언트는 `choose_move`, `choose_switch`, `choose_replacement`, `forfeit` 같은 **의도(intent)** 만 보낸다. 명중, 우선순위, 속도, 대미지, 상태 변화, 승패는 모두 서버가 계산한다. ### 2. 서버 이벤트는 플레이어별 투영 결과다 -동일 턴이라도 각 플레이어가 보는 payload는 달라질 수 있다. +동일 턴이라도 각 플레이어가 보는 payload는 달라질 수 있다. 특히 상대 백라인, 숨은 기술 세부, 비공개 상태는 조기 공개하지 않는다. ### 3. 배틀 시작 시 선발은 자동 고정이다 @@ -626,7 +626,7 @@ awaiting_presence ## Phase 3 결론 -초기 PvP의 실시간성은 “액션 게임식 프레임 동기화”가 아니라, **턴 요청과 authoritative 이벤트 스트림을 주고받는 실시간 세션**으로 구현하는 것이 맞다. +초기 PvP의 실시간성은 “액션 게임식 프레임 동기화”가 아니라, **턴 요청과 authoritative 이벤트 스트림을 주고받는 실시간 세션**으로 구현하는 것이 맞다. 서버는 명령 수집과 결과 계산을 전부 책임지고, 클라이언트는 오직 현재 자신에게 공개된 상태와 입력 가능 행동만 처리해야 한다. --- diff --git a/docs/pvp/server/contracts/room-and-match.md b/docs/pvp/server/contracts/room-and-match.md index 44cc4649..1cc02171 100644 --- a/docs/pvp/server/contracts/room-and-match.md +++ b/docs/pvp/server/contracts/room-and-match.md @@ -1,11 +1,11 @@ # 친구전 룸 / 매치 성립 상세 계약 -상위 문서: [PvP 서버 Contracts 문서](./README.md) +상위 문서: [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 계약**을 상세화한다. +이 문서는 초기 PvP의 **Phase 2 계약**을 상세화한다. 범위는 다음 세 가지다. 1. `POST /api/pvp/rooms` @@ -40,7 +40,7 @@ 활성 파티는 재등록될 수 있지만, **이미 성립한 룸/배틀이 참조하는 snapshot**은 바뀌지 않는다. ### 4. 친선전 초기 버전에는 별도 ready 버튼 없이 곧바로 시작 준비로 넘어간다 -사용자 경험은 “친구 코드 입력 → 붙으면 바로 배틀 진입”에 가깝게 간다. +사용자 경험은 “친구 코드 입력 → 붙으면 바로 배틀 진입”에 가깝게 간다. 따라서 별도 `ready` API는 두지 않고, 서버가 참가 성공 시 **match readiness**를 계산한다. ### 5. 상대 백라인 공개는 룸 단계에서도 금지한다 @@ -476,7 +476,7 @@ Body 없음. ## Phase 2 결론 -초기 친선 PvP에서 룸은 단순 입장 절차가 아니라, **배틀 직전의 server-authoritative 고정 지점**이다. +초기 친선 PvP에서 룸은 단순 입장 절차가 아니라, **배틀 직전의 server-authoritative 고정 지점**이다. 등록된 활성 파티와 ruleset을 룸에서 한 번 더 맞추고, 여기서 battle freeze를 만들어야 이후 실시간 배틀이 흔들리지 않는다. --- diff --git a/docs/pvp/server/storage/README.md b/docs/pvp/server/storage/README.md index 3c5237ff..ec49c02d 100644 --- a/docs/pvp/server/storage/README.md +++ b/docs/pvp/server/storage/README.md @@ -2,7 +2,7 @@ 상위 문서: [서버 설계 인덱스](../README.md) -이 섹션은 서버 권한 PvP를 뒷받침하는 **엔티티 구조 / 저장 책임 / DB 스키마**를 정리한다. +이 섹션은 서버 권한 PvP를 뒷받침하는 **엔티티 구조 / 저장 책임 / DB 스키마**를 정리한다. 즉, “무엇을 어떤 단위로 저장하고, 어떤 제약으로 무결성을 유지할지”를 설명하는 하위 묶음이다. ## 포함 문서 diff --git a/docs/pvp/server/storage/data-model.md b/docs/pvp/server/storage/data-model.md index 52e65014..f714fe8a 100644 --- a/docs/pvp/server/storage/data-model.md +++ b/docs/pvp/server/storage/data-model.md @@ -1,6 +1,6 @@ # PvP 서버 데이터 모델 -상위 문서: [PvP 서버 Storage 문서](./README.md) +상위 문서: [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) ## 목표 diff --git a/docs/pvp/server/storage/database-schema.md b/docs/pvp/server/storage/database-schema.md index d5389dfe..aceffce4 100644 --- a/docs/pvp/server/storage/database-schema.md +++ b/docs/pvp/server/storage/database-schema.md @@ -1,11 +1,11 @@ # PvP 서버 DB 스키마 초안 -상위 문서: [PvP 서버 Storage 문서](./README.md) +상위 문서: [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 스키마 초안**으로 구체화한다. +이 문서는 `data-model.md`의 high-level 엔티티를 **실제 구현 직전 수준의 DB 스키마 초안**으로 구체화한다. 즉, “어떤 테이블이 필요하다”에서 한 단계 더 들어가서 아래를 고정한다. 1. 각 테이블의 책임 @@ -30,7 +30,7 @@ ### 플레이어 테이블은 외부 시스템으로 둔다 -현재 Tokénmon repo에는 온라인 계정 canonical table이 아직 없다. +현재 Tokénmon repo에는 온라인 계정 canonical table이 아직 없다. 따라서 이 문서에서는 `player_id`를 **인증 레이어가 보장하는 안정적인 opaque identifier** 로 취급한다. 예: @@ -43,12 +43,12 @@ ### 온라인 등록은 accepted snapshot만 저장한다 -초기 버전에서는 `online_party_snapshots`에 **서버 검증을 통과한 스냅샷만** 저장하는 쪽을 권장한다. +초기 버전에서는 `online_party_snapshots`에 **서버 검증을 통과한 스냅샷만** 저장하는 쪽을 권장한다. 거부된 등록 시도 로그는 운영 테이블로 분리하는 것이 낫다. ### 온라인 배틀은 등록 시점의 정규화 결과를 사용한다 -배틀 직전 로컬 저장을 다시 읽지 않는다. +배틀 직전 로컬 저장을 다시 읽지 않는다. 배틀은 반드시 다음 두 가지를 기준으로 시작한다. - 서버가 승인한 `online_party_snapshots` @@ -107,7 +107,7 @@ generation_rulesets ### 메모 -세대별 현재 활성 ruleset은 1개만 두는 편이 운영이 단순하다. +세대별 현재 활성 ruleset은 1개만 두는 편이 운영이 단순하다. 단, 과거 배틀 재현을 위해 old ruleset row는 soft-delete 하지 말고 남겨두는 것을 권장한다. --- @@ -132,7 +132,7 @@ generation_rulesets ### 메모 -`legendary`, `mythical`은 종 메타데이터에서 오고, `restricted`는 **온라인 밸런스 정책 레이어**에서 온다. +`legendary`, `mythical`은 종 메타데이터에서 오고, `restricted`는 **온라인 밸런스 정책 레이어**에서 온다. 따라서 `restricted`는 별도 테이블로 두는 것이 맞다. --- @@ -249,7 +249,7 @@ generation_rulesets ### 설계 포인트 -가장 중요한 컬럼은 `ruleset_snapshot_json`이다. +가장 중요한 컬럼은 `ruleset_snapshot_json`이다. 이 값을 저장해야, 나중에 restricted 목록이나 레벨 정책이 바뀌더라도 **과거 배틀은 당시 기준 그대로 재현**할 수 있다. --- @@ -278,7 +278,7 @@ generation_rulesets ### 설계 포인트 -온라인 배틀 시작 이후에는 이 row가 **어떤 snapshot으로 싸웠는지**를 고정한다. +온라인 배틀 시작 이후에는 이 row가 **어떤 snapshot으로 싸웠는지**를 고정한다. 중간에 active party를 재등록해도 이미 시작된 배틀에는 영향을 주지 않는다. --- @@ -441,17 +441,17 @@ generation_rulesets ### 로컬 저장 전문(raw save blob) -초기 버전에는 과하다. +초기 버전에는 과하다. 해시와 정규화된 성장 결과, 그리고 `growth_proof_json` 정도면 친선전 수준의 초기 보호에는 충분하다. ### spectator / replay / ladder 전용 테이블 -지금 넣으면 설계가 빨리 커진다. +지금 넣으면 설계가 빨리 커진다. 친선 PvP v1 범위를 넘는 기능은 후속 migration으로 분리하는 편이 낫다. ### multiple active party slots -초기에는 generation별 active slot 1개가 운영/UX/검증 모두 가장 단순하다. +초기에는 generation별 active slot 1개가 운영/UX/검증 모두 가장 단순하다. 이후 확장 시 `slot_name` 또는 `slot_index` 개념을 `online_party_snapshots`에 추가하면 된다. --- From a087bffc01f21ac65a89792b9481e83f5929efac Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 15:54:12 +0900 Subject: [PATCH 04/30] Establish a server-side PvP ruleset baseline before party registration The multiplayer rollout needs one authoritative source for generation rules, restricted species, and effective battle level compression before validation, registration, or room flows can be implemented safely. This commit adds the first server-side rules module, locks the initial policy contract in tests, and links the implementation docs to issue-sized execution slices. Constraint: Early PvP work must preserve story-grown party identity while still enforcing server-authoritative battle rules Constraint: ISSUE-01 is intentionally limited to static policy data and pure logic with no HTTP, DB, or WebSocket coupling Rejected: Start with party registration endpoints first | validation semantics would be unstable without a fixed ruleset contract Rejected: Put PvP policy under src/core immediately | online-only authority boundaries are clearer under src/server Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep future registration and battle flows consuming these exported rules contracts instead of re-encoding policy at the edge Tested: node --import tsx --test test/pvp-ruleset.test.ts Tested: npm run typecheck Tested: npm test Tested: git diff --check Not-tested: Runtime server integration because no PvP HTTP/WS surface exists yet Related: docs/pvp/implementation/issues/ISSUE-01-ruleset-foundation.md --- docs/pvp/implementation/README.md | 3 + .../issues/ISSUE-01-ruleset-foundation.md | 77 +++++++++++++++++ .../ISSUE-02-party-validation-domain.md | 59 +++++++++++++ .../ISSUE-03-party-registration-surface.md | 53 ++++++++++++ .../ISSUE-04-room-domain-and-persistence.md | 43 ++++++++++ .../ISSUE-05-room-http-and-readiness.md | 36 ++++++++ .../issues/ISSUE-06-battle-session-domain.md | 42 +++++++++ .../ISSUE-07-realtime-command-gateway.md | 34 ++++++++ .../issues/ISSUE-08-reconnect-and-ops.md | 36 ++++++++ docs/pvp/implementation/issues/README.md | 57 ++++++++++++ docs/pvp/implementation/todo-breakdown.md | 3 + src/server/rules/index.ts | 25 ++++++ src/server/rules/level-compression.ts | 45 ++++++++++ src/server/rules/restricted-species.ts | 17 ++++ src/server/rules/ruleset-repository.ts | 86 +++++++++++++++++++ src/server/rules/ruleset-service.ts | 70 +++++++++++++++ src/server/rules/ruleset-types.ts | 75 ++++++++++++++++ test/pvp-ruleset.test.ts | 56 ++++++++++++ 18 files changed, 817 insertions(+) create mode 100644 docs/pvp/implementation/issues/ISSUE-01-ruleset-foundation.md create mode 100644 docs/pvp/implementation/issues/ISSUE-02-party-validation-domain.md create mode 100644 docs/pvp/implementation/issues/ISSUE-03-party-registration-surface.md create mode 100644 docs/pvp/implementation/issues/ISSUE-04-room-domain-and-persistence.md create mode 100644 docs/pvp/implementation/issues/ISSUE-05-room-http-and-readiness.md create mode 100644 docs/pvp/implementation/issues/ISSUE-06-battle-session-domain.md create mode 100644 docs/pvp/implementation/issues/ISSUE-07-realtime-command-gateway.md create mode 100644 docs/pvp/implementation/issues/ISSUE-08-reconnect-and-ops.md create mode 100644 docs/pvp/implementation/issues/README.md create mode 100644 src/server/rules/index.ts create mode 100644 src/server/rules/level-compression.ts create mode 100644 src/server/rules/restricted-species.ts create mode 100644 src/server/rules/ruleset-repository.ts create mode 100644 src/server/rules/ruleset-service.ts create mode 100644 src/server/rules/ruleset-types.ts create mode 100644 test/pvp-ruleset.test.ts diff --git a/docs/pvp/implementation/README.md b/docs/pvp/implementation/README.md index 4210952e..5faeee45 100644 --- a/docs/pvp/implementation/README.md +++ b/docs/pvp/implementation/README.md @@ -9,12 +9,14 @@ 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에게 바로 넘길 수 있는 실제 구현 단위를 확인한다. ## 구현 시 먼저 볼 상세 계약 @@ -22,3 +24,4 @@ - 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)에서 현재 이슈의 범위와 완료 조건을 먼저 확인한다. 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/README.md b/docs/pvp/implementation/issues/README.md new file mode 100644 index 00000000..e06dbfd0 --- /dev/null +++ b/docs/pvp/implementation/issues/README.md @@ -0,0 +1,57 @@ +# 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) + +## 왜 이 순서인가 + +PvP에서 가장 먼저 고정되어야 하는 것은 **온라인에서 무엇이 합법 파티인지**다. +ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정되지 않으면 파티 등록, 룸 매칭, 배틀 로그 해석이 모두 흔들린다. + +따라서 초기 착수는 반드시 **정책 계층 → 검증 계층 → 등록 surface** 순으로 간다. + +## 첫 실행 대상 + +현재 첫 구현 대상은 [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 | + +## 공통 실행 규칙 + +- 각 이슈는 가능하면 **테스트 우선(TDD)** 으로 진행한다. +- `src/core`를 수정해야 한다면, 서버 계층이 요구하는 최소한의 재사용 지점만 만든다. +- 서버가 최종 권위자인 값은 클라이언트 입력에서 받지 않는다. +- 이후 이슈가 필요로 하는 식별자(`rulesetKey`, `snapshotId`, `roomId`, `battleId`)는 초기에 안정 키로 설계한다. diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index 73196d0d..db292f88 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -2,12 +2,15 @@ 상위 문서: [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. 규칙/정책 고정 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/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`); + } +}); From fd58a969381ea1b8c06b3fb2cbc87d59faa9eced Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 16:06:12 +0900 Subject: [PATCH 05/30] Protect PvP party registration with deterministic server-side validation The online party validator now normalizes valid six-member parties, reuses the ISSUE-01 ruleset layer for level compression and restricted-species policy, and rejects client-side tampering signals before HTTP integration is added. Constraint: ISSUE-02 owns only src/server/parties/* plus the targeted validator test Constraint: Effective level behavior must stay aligned with committed ISSUE-01 code/tests even where docs examples differ Rejected: Fold validation into an HTTP handler now | ISSUE-03 owns transport concerns Rejected: Add permissive partial-success output | registration must fail closed on anti-cheat mismatches Confidence: high Scope-risk: narrow Directive: Keep party validation pure and reuse rules/source-of-truth helpers before adding request-layer logic Tested: node --import tsx --test test/pvp-party-validator.test.ts Tested: npm run typecheck Not-tested: End-to-end HTTP registration flow (reserved for ISSUE-03) --- src/server/parties/growth-proof.ts | 95 +++++++++ src/server/parties/index.ts | 15 ++ src/server/parties/party-types.ts | 107 ++++++++++ src/server/parties/party-validator.ts | 282 ++++++++++++++++++++++++++ test/pvp-party-validator.test.ts | 169 +++++++++++++++ 5 files changed, 668 insertions(+) create mode 100644 src/server/parties/growth-proof.ts create mode 100644 src/server/parties/index.ts create mode 100644 src/server/parties/party-types.ts create mode 100644 src/server/parties/party-validator.ts create mode 100644 test/pvp-party-validator.test.ts 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..5dfa1329 --- /dev/null +++ b/src/server/parties/index.ts @@ -0,0 +1,15 @@ +export { validateGrowthProof } from './growth-proof.js'; +export { validateOnlineParty } from './party-validator.js'; +export { PARTY_VALIDATION_ERROR_CODES } from './party-types.js'; +export type { + GrowthProofInput, + GrowthProofMemberInput, + OnlinePartyMemberInput, + OnlinePartyMemberSnapshotDraft, + OnlinePartySnapshotDraft, + PartyValidationErrorCode, + PartyValidationIssue, + PartyValidationResult, + ValidateOnlinePartyInput, +} from './party-types.js'; +export type { PvpGeneration } from '../rules/index.js'; diff --git a/src/server/parties/party-types.ts b/src/server/parties/party-types.ts new file mode 100644 index 00000000..4b370605 --- /dev/null +++ b/src/server/parties/party-types.ts @@ -0,0 +1,107 @@ +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[]; + }; 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/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)); +}); From 1bcbb2f6536ce32f93a9882dfc12a7e655579bf2 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 16:34:48 +0900 Subject: [PATCH 06/30] Enable server-owned active PvP party registration flows Added in-memory snapshot persistence, a registration service, and thin HTTP route surfaces so online PvP can replace one active roster per generation, reuse the validated party snapshot shape, and keep snapshot versioning under server control.\n\nConstraint: Initial multiplayer must treat the server as the authority for party registration and battle inputs\nConstraint: Existing validator and ruleset layers must stay reusable without new dependencies\nRejected: Store party snapshots client-side only | cannot prevent forged registrations or version drift\nRejected: Expose full growth proof in route responses | leaks verification payload unnecessarily\nConfidence: high\nScope-risk: moderate\nDirective: Keep later battle/session layers consuming ActivePartySnapshot rather than raw client payloads\nTested: npm run typecheck\nTested: npm test\nTested: node --import tsx --test test/pvp-party-registration-service.test.ts test/pvp-routes.test.ts\nTested: git diff --check\nNot-tested: Real HTTP server wiring and durable database persistence --- src/server/http/http-types.ts | 23 ++ src/server/http/pvp-party-routes.ts | 214 ++++++++++++++ src/server/http/pvp-rules-routes.ts | 70 +++++ src/server/index.ts | 5 + src/server/parties/index.ts | 12 + .../parties/party-registration-service.ts | 262 ++++++++++++++++++ .../parties/party-snapshot-repository.ts | 106 +++++++ src/server/parties/party-types.ts | 32 +++ test/pvp-party-registration-service.test.ts | 156 +++++++++++ test/pvp-routes.test.ts | 196 +++++++++++++ 10 files changed, 1076 insertions(+) create mode 100644 src/server/http/http-types.ts create mode 100644 src/server/http/pvp-party-routes.ts create mode 100644 src/server/http/pvp-rules-routes.ts create mode 100644 src/server/index.ts create mode 100644 src/server/parties/party-registration-service.ts create mode 100644 src/server/parties/party-snapshot-repository.ts create mode 100644 test/pvp-party-registration-service.test.ts create mode 100644 test/pvp-routes.test.ts diff --git a/src/server/http/http-types.ts b/src/server/http/http-types.ts new file mode 100644 index 00000000..99069754 --- /dev/null +++ b/src/server/http/http-types.ts @@ -0,0 +1,23 @@ +export interface HttpAuthContext { + playerId?: string; +} + +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-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-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..17d6df87 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,5 @@ +export { createPvpPartyRoutes } from './http/pvp-party-routes.js'; +export { createPvpRulesRoutes } from './http/pvp-rules-routes.js'; +export type { ErrorEnvelope, HttpRequest, HttpResponse } from './http/http-types.js'; +export * from './parties/index.js'; +export * from './rules/index.js'; diff --git a/src/server/parties/index.ts b/src/server/parties/index.ts index 5dfa1329..ead025fa 100644 --- a/src/server/parties/index.ts +++ b/src/server/parties/index.ts @@ -1,7 +1,17 @@ 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, @@ -10,6 +20,8 @@ export type { 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 index 4b370605..71e6e3ae 100644 --- a/src/server/parties/party-types.ts +++ b/src/server/parties/party-types.ts @@ -105,3 +105,35 @@ export type PartyValidationResult = 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/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-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'); +}); From 0cd4a7ae9df8ed505ed7d4998ab63eebd330d246 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 16:53:39 +0900 Subject: [PATCH 07/30] Protect room creation and join flow with server-owned battle room state This adds the room domain and in-memory persistence needed for friendly private PvP rooms. The server now owns room IDs, room codes, host/guest bindings, and the frozen battle-start snapshot that captures both accepted party snapshots plus the active ruleset. The implementation intentionally stops at the domain/service layer. HTTP surfaces, readiness transitions, and realtime presence remain for the next issue so the room contract can stabilize first. Constraint: ISSUE-04 stops before HTTP, presence, and battle command orchestration Constraint: Existing PvP room state is currently in-memory only Rejected: Client-authored room state | would weaken anti-cheat trust boundaries Rejected: Delaying freeze creation until battle start | would blur room/join contract ownership Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep room authority server-owned; do not move join validation back to clients Tested: node --import tsx --test test/pvp-room-service.test.ts Tested: npm run typecheck Tested: npm test Tested: git diff --check Tested: npx tsc --noEmit --pretty false --project tsconfig.json Not-tested: multi-process/shared-database room code allocation Not-tested: HTTP/readiness/presence integration (reserved for ISSUE-05) --- src/server/index.ts | 1 + src/server/rooms/index.ts | 31 ++ src/server/rooms/room-code.ts | 30 ++ src/server/rooms/room-repository.ts | 93 ++++++ src/server/rooms/room-service.ts | 425 ++++++++++++++++++++++++++++ src/server/rooms/room-types.ts | 78 +++++ src/server/rooms/room-validator.ts | 225 +++++++++++++++ test/pvp-room-service.test.ts | 353 +++++++++++++++++++++++ 8 files changed, 1236 insertions(+) create mode 100644 src/server/rooms/index.ts create mode 100644 src/server/rooms/room-code.ts create mode 100644 src/server/rooms/room-repository.ts create mode 100644 src/server/rooms/room-service.ts create mode 100644 src/server/rooms/room-types.ts create mode 100644 src/server/rooms/room-validator.ts create mode 100644 test/pvp-room-service.test.ts diff --git a/src/server/index.ts b/src/server/index.ts index 17d6df87..f2f221a3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,3 +3,4 @@ export { createPvpRulesRoutes } from './http/pvp-rules-routes.js'; export type { ErrorEnvelope, HttpRequest, HttpResponse } from './http/http-types.js'; export * from './parties/index.js'; export * from './rules/index.js'; +export * from './rooms/index.js'; 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/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; + }, + ); +}); From efdfe5e77661c04da2fa55576c43aa862228b0e2 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 17:08:23 +0900 Subject: [PATCH 08/30] Expose room staging flow through authenticated PvP HTTP projections This adds the room HTTP surface for create, join, and get flows along with a viewer-specific projection layer for room snapshots. The server now returns participant-scoped room views that preserve the anti-cheat boundary by hiding opponent party snapshot details while still showing readiness and presence state needed for friendly battle staging. The implementation intentionally stops at HTTP plus projection. WebSocket presence, battle start orchestration, and turn handling stay in later issues so room staging can stabilize as an explicit contract. Constraint: ISSUE-05 is limited to HTTP room staging and viewer-specific projection Constraint: No player profile/display-name service exists yet, so projection uses userId as minimal displayName Rejected: Expose opponent party snapshot metadata in room views | violates hidden-information contract Rejected: Fold projection logic into route handlers | would blur truth vs visibility boundaries Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep room projection participant-scoped; do not expose opponent party data through GET room Tested: node --import tsx --test test/pvp-room-routes.test.ts Tested: npm run typecheck Tested: git diff --check Tested: npm test Not-tested: profile-backed display name mapping Not-tested: websocket presence/starting transition integration (reserved for ISSUE-06/07) --- src/server/http/pvp-room-routes.ts | 194 ++++++++++++++++++ src/server/index.ts | 2 + src/server/projection/index.ts | 1 + src/server/projection/room-projection.ts | 134 +++++++++++++ test/pvp-room-routes.test.ts | 241 +++++++++++++++++++++++ 5 files changed, 572 insertions(+) create mode 100644 src/server/http/pvp-room-routes.ts create mode 100644 src/server/projection/index.ts create mode 100644 src/server/projection/room-projection.ts create mode 100644 test/pvp-room-routes.test.ts 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/index.ts b/src/server/index.ts index f2f221a3..7a2a32d8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,8 @@ export { createPvpPartyRoutes } from './http/pvp-party-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 './parties/index.js'; export * from './rules/index.js'; export * from './rooms/index.js'; +export * from './projection/index.js'; diff --git a/src/server/projection/index.ts b/src/server/projection/index.ts new file mode 100644 index 00000000..7ea2e8db --- /dev/null +++ b/src/server/projection/index.ts @@ -0,0 +1 @@ +export { projectRoomView, RoomProjectionError, type RoomView } from './room-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/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'); +}); From f8938aacc8f6be9dcde447b88bffcae0e89e38f2 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 17:55:39 +0900 Subject: [PATCH 09/30] Centralize PvP turn resolution under a server-owned battle session This introduces the authoritative PvP battle domain that sits between room readiness and the future realtime gateway. The new battle module validates seat commands, adapts the existing turn engine into a server-owned session model, emits viewer-scoped events, and records replacement / forfeit transitions without trusting the client. The change also exports the battle surface from src/server/index.ts and locks the contract with session-level tests so ISSUE-07 can focus on WebSocket transport rather than battle semantics. Constraint: Initial multiplayer must keep all battle outcomes server-authoritative to limit result forgery Constraint: Opponent bench details and move lists must remain hidden from the other player Rejected: Let the client resolve turns locally and only sync results | opens result forgery and desync risk Rejected: Bundle transport concerns into the battle domain issue | would blur the transport/domain boundary before reconnect work lands Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep transport-layer code as a thin adapter over BattleSessionService; do not duplicate battle state transitions in ws handlers Tested: node --import tsx --test test/pvp-battle-session.test.ts Tested: npm run typecheck Tested: git diff --check Tested: npm test Not-tested: Real WebSocket fan-out and reconnect behavior (deferred to ISSUE-07/08) --- src/server/battle/battle-command-service.ts | 188 +++++++ src/server/battle/battle-engine-adapter.ts | 175 +++++++ src/server/battle/battle-event-log.ts | 391 ++++++++++++++ src/server/battle/battle-session-service.ts | 335 ++++++++++++ src/server/battle/battle-turn-service.ts | 197 +++++++ src/server/battle/battle-types.ts | 376 +++++++++++++ src/server/battle/index.ts | 30 ++ src/server/index.ts | 1 + test/pvp-battle-session.test.ts | 550 ++++++++++++++++++++ 9 files changed, 2243 insertions(+) create mode 100644 src/server/battle/battle-command-service.ts create mode 100644 src/server/battle/battle-engine-adapter.ts create mode 100644 src/server/battle/battle-event-log.ts create mode 100644 src/server/battle/battle-session-service.ts create mode 100644 src/server/battle/battle-turn-service.ts create mode 100644 src/server/battle/battle-types.ts create mode 100644 src/server/battle/index.ts create mode 100644 test/pvp-battle-session.test.ts diff --git a/src/server/battle/battle-command-service.ts b/src/server/battle/battle-command-service.ts new file mode 100644 index 00000000..75327fba --- /dev/null +++ b/src/server/battle/battle-command-service.ts @@ -0,0 +1,188 @@ +import type { RoomSeat } from '../rooms/index.js'; +import { + collectAliveBenchSlots, + getActiveBattlePokemon, +} from './battle-engine-adapter.js'; +import type { + BattleCommandEnvelope, + BattleCommandRejectionCode, + 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; +}): BattleCommandValidationResult { + const { session, seat, envelope } = 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 (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..517118c1 --- /dev/null +++ b/src/server/battle/battle-session-service.ts @@ -0,0 +1,335 @@ +import type { RoomSeat } from '../rooms/index.js'; +import { + buildSeatRuntimeState, + createAuthoritativeBattleState, +} from './battle-engine-adapter.js'; +import { validateBattleCommand } from './battle-command-service.js'; +import { + createActionRequestPayload, + createReplacementRequestPayload, + createViewerVisibleState, + projectEventsForViewer, +} from './battle-event-log.js'; +import { + createEndedPayloadResult, + resolveForfeit, + resolveSubmittedActions, + resolveSubmittedReplacements, +} from './battle-turn-service.js'; +import type { + BattleLoggedEvent, + BattleCommandEnvelope, + BattleCommandPhase, + BattleDataResolver, + BattleServerEventEnvelope, + BattleSessionCreateInput, + BattleSessionMutationResult, + BattleSessionRecord, + BattleSessionSubmitInput, +} from './battle-types.js'; + +export interface BattleSessionServiceOptions { + dataResolver: BattleDataResolver; + now?: () => Date; + battleIdGenerator?: () => string; +} + +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: {}, + seenClientCommandIds: [], + nextSeq: 1, + result: null, + createdAt, + updatedAt: createdAt, + }; + + const sentAt = this.now().toISOString(); + const eventsBySeat = this.createEmptySeatEventMap(); + for (const seat of ['host', 'guest'] as const) { + this.pushEvent(eventsBySeat, seat, { + type: 'room.snapshot', + roomId: session.roomId, + battleId: session.battleId, + seq: this.nextSeq(session), + sentAt, + payload: { + roomStatus: session.roomStatus, + battleStatus: session.phase, + generation: session.generation, + rulesetKey: session.rulesetKey, + yourSeat: seat, + turn: session.turn, + visibleState: createViewerVisibleState(session, seat), + pendingRequest: { + kind: 'choose_move_or_switch', + deadlineMs: session.rulesetSnapshot.battlePolicy.actionTimeoutSeconds * 1000, + }, + }, + }); + this.pushEvent(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 eventsBySeat = this.createEmptySeatEventMap(); + const validation = validateBattleCommand({ session, seat, envelope }); + if (!validation.ok) { + const sentAt = this.now().toISOString(); + this.pushEvent(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 = this.now().toISOString(); + this.pushEvent(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 finishedAt = this.now().toISOString(); + const resolution = resolveForfeit({ + session, + forfeitingSeat: seat, + recordedAt: finishedAt, + }); + this.emitResolutionEvents(eventsBySeat, session, resolution.events, resolution.nextPhase, finishedAt); + this.emitBattleEnded(eventsBySeat, session, finishedAt); + session.pendingCommands = {}; + session.pendingReplacementCommands = {}; + session.pendingReplacementSeats = []; + 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.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.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 }; + } + + private emitResolutionEvents( + eventsBySeat: Record, + session: BattleSessionRecord, + events: BattleLoggedEvent[], + nextPhase: 'awaiting_actions' | 'awaiting_replacement' | 'finished', + sentAt: string, + ) { + for (const seat of ['host', 'guest'] as const) { + this.pushEvent(eventsBySeat, seat, { + type: 'battle.turn_resolved', + roomId: session.roomId, + battleId: session.battleId, + seq: this.nextSeq(session), + sentAt, + payload: { + turn: nextPhase === 'awaiting_actions' && session.phase === 'awaiting_actions' ? session.turn - 1 : session.turn, + events: projectEventsForViewer(seat, events), + postTurnVisibleState: createViewerVisibleState(session, seat), + nextPhase, + }, + }); + } + } + + private emitActionRequests( + eventsBySeat: Record, + session: BattleSessionRecord, + sentAt: string, + ) { + for (const seat of ['host', 'guest'] as const) { + this.pushEvent(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(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 ['host', 'guest'] as const) { + this.pushEvent(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( + eventsBySeat: Record, + seat: RoomSeat, + event: BattleServerEventEnvelope, + ): void { + eventsBySeat[seat].push(event); + } + + 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..f7b18211 --- /dev/null +++ b/src/server/battle/battle-turn-service.ts @@ -0,0 +1,197 @@ +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.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.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.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 { + const { session, forfeitingSeat, recordedAt } = args; + const winnerSeat: RoomSeat = forfeitingSeat === 'host' ? 'guest' : 'host'; + const result: BattleSessionResult = { + winnerSeat, + loserSeat: forfeitingSeat, + reason: 'forfeit', + recordedAt, + }; + session.phase = 'finished'; + 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 = winnerSeat === 'host' + ? 'all_opponent_pokemon_fainted' + : '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..371455d5 --- /dev/null +++ b/src/server/battle/battle-types.ts @@ -0,0 +1,376 @@ +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 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; + } + | { + kind: 'choose_replacement'; + deadlineMs: number; + } + | 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>; + 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; +} + +export interface BattleSessionMutationResult { + session: BattleSessionRecord; + eventsBySeat: Record; +} diff --git a/src/server/battle/index.ts b/src/server/battle/index.ts new file mode 100644 index 00000000..787fd28a --- /dev/null +++ b/src/server/battle/index.ts @@ -0,0 +1,30 @@ +export { BattleSessionService, type BattleSessionServiceOptions } from './battle-session-service.js'; +export { + BATTLE_COMMAND_REJECTION_CODES, + type BattleActionRequestPayload, + type BattleCommand, + type BattleCommandAcceptedPayload, + type BattleCommandEnvelope, + type BattleCommandPhase, + type BattleCommandRejectedPayload, + type BattleCommandRejectionCode, + type BattleDataResolver, + type BattleEndedPayload, + type BattleFinishReason, + type BattleLoggedEvent, + type BattleReplacementRequestPayload, + type BattleRequestKind, + 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/index.ts b/src/server/index.ts index 7a2a32d8..f3f0dc85 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,6 +2,7 @@ export { createPvpPartyRoutes } from './http/pvp-party-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'; 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'); + }); +}); From debb5477e34f885e48e8acd6e0b438448b6c9144 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 18:25:38 +0900 Subject: [PATCH 10/30] Route live PvP commands through a server-owned websocket gateway The websocket layer now authenticates room seats, starts the authoritative\nbattle session when both players are online, routes battle.command traffic\nthrough the server, and only pushes seat-scoped battle events back to each\nclient. Heartbeat sweeps and duplicate-seat rejection are included so the\ntransport contract matches the PvP design docs and keeps stale clients from\ncontrolling a room.\n\nConstraint: Initial multiplayer must keep all battle resolution on the server\nConstraint: Clients may only send commands and render server-authored events\nRejected: Let clients simulate turns locally and reconcile later | enables result forgery and state divergence\nRejected: Allow multiple simultaneous seat connections | complicates authority and reconnect semantics too early\nConfidence: high\nScope-risk: moderate\nReversibility: clean\nDirective: Preserve seat-scoped event projection when extending reconnect flows\nTested: node --import tsx --test test/pvp-ws-gateway.test.ts\nTested: npm run typecheck\nTested: npm test\nNot-tested: Real network websocket adapter integration outside the in-memory transport harness --- src/server/index.ts | 1 + src/server/ws/connection-registry.ts | 139 ++++++++ src/server/ws/heartbeat.ts | 69 ++++ src/server/ws/index.ts | 28 ++ src/server/ws/message-router.ts | 60 ++++ src/server/ws/pvp-ws-server.ts | 337 ++++++++++++++++++ test/pvp-ws-gateway.test.ts | 487 +++++++++++++++++++++++++++ 7 files changed, 1121 insertions(+) create mode 100644 src/server/ws/connection-registry.ts create mode 100644 src/server/ws/heartbeat.ts create mode 100644 src/server/ws/index.ts create mode 100644 src/server/ws/message-router.ts create mode 100644 src/server/ws/pvp-ws-server.ts create mode 100644 test/pvp-ws-gateway.test.ts diff --git a/src/server/index.ts b/src/server/index.ts index f3f0dc85..203fb418 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -7,3 +7,4 @@ 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/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..021d05cf --- /dev/null +++ b/src/server/ws/pvp-ws-server.ts @@ -0,0 +1,337 @@ +import { + BattleSessionService, + type BattleCommandEnvelope, + type BattleSessionRecord, +} from '../battle/index.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; +} + +export interface PvpWsConnectionSummary { + connectionId: string; + seat: RoomSeat; + battleId: string | null; +} + +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 duplicate = this.registry.getBySeat(input.roomId, seat); + if (duplicate) { + input.transport.close(4001, 'PVP_WS_DUPLICATE_CONNECTION'); + throw createError('PVP_WS_DUPLICATE_CONNECTION'); + } + + 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); + + return { + connectionId: connection.connectionId, + seat, + battleId: startedSession?.battleId ?? existingSession?.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.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); + } + + 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', + }); + }); + } + + getBattleSession(roomId: string): BattleSessionRecord | undefined { + const session = this.sessionsByRoomId.get(roomId); + return session ? cloneSession(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.sessionsByRoomId.set(result.session.roomId, cloneSession(result.session)); + this.registry.updateBattleIdForRoom(result.session.roomId, result.session.battleId); + this.dispatchEvents(result.session.roomId, 'host', result.eventsBySeat.host); + this.dispatchEvents(result.session.roomId, 'guest', result.eventsBySeat.guest); + + return 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 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-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); + }); +}); From cf4aed2ac1d03f9d5c1ef24450e9dd1479427fb5 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 19:04:41 +0900 Subject: [PATCH 11/30] Keep PvP rooms authoritative across reconnects and timeouts ISSUE-08 adds reconnect resume snapshots, timeout-driven auto commands, and an operator debug view around the authoritative battle session so the websocket server can recover clients without trusting local state. The final green step keeps the test-only PvpGeneration imports pointed at the rules surface instead of widening the battle exports. Constraint: Battle outcomes and growth state must remain server-authoritative Constraint: Minimum delivery was scoped to reconnect/timeout/debug-view coverage Rejected: Client-side timeout reconciliation | would weaken cheat resistance Rejected: Re-export PvpGeneration from every server surface | created avoidable type export collisions Confidence: high Scope-risk: moderate Directive: Keep resume payloads and debug views derived from persisted session state only Tested: node --import tsx --test test/pvp-reconnect.test.ts test/pvp-timeout-policy.test.ts Tested: npm run typecheck Tested: npx tsc --noEmit --pretty false --project /home/minsiwon00/claude/.worktrees/docs-pvp-contracts/tsconfig.json --- src/server/battle/battle-command-service.ts | 18 +- src/server/battle/battle-session-service.ts | 232 ++++++++-- src/server/battle/battle-turn-service.ts | 29 +- src/server/battle/battle-types.ts | 55 +++ src/server/battle/index.ts | 11 + src/server/battle/timeout-policy.ts | 131 ++++++ src/server/projection/battle-projection.ts | 64 +++ src/server/projection/index.ts | 1 + src/server/ws/pvp-ws-server.ts | 132 +++++- test/pvp-reconnect.test.ts | 461 ++++++++++++++++++++ test/pvp-timeout-policy.test.ts | 418 ++++++++++++++++++ 11 files changed, 1495 insertions(+), 57 deletions(-) create mode 100644 src/server/battle/timeout-policy.ts create mode 100644 src/server/projection/battle-projection.ts create mode 100644 test/pvp-reconnect.test.ts create mode 100644 test/pvp-timeout-policy.test.ts diff --git a/src/server/battle/battle-command-service.ts b/src/server/battle/battle-command-service.ts index 75327fba..f8e32e9a 100644 --- a/src/server/battle/battle-command-service.ts +++ b/src/server/battle/battle-command-service.ts @@ -6,6 +6,7 @@ import { import type { BattleCommandEnvelope, BattleCommandRejectionCode, + BattleCommandSource, BattleSessionRecord, } from './battle-types.js'; import { BATTLE_COMMAND_REJECTION_CODES } from './battle-types.js'; @@ -56,8 +57,10 @@ export function validateBattleCommand(args: { session: BattleSessionRecord; seat: RoomSeat; envelope: BattleCommandEnvelope; + now: Date; + source: BattleCommandSource; }): BattleCommandValidationResult { - const { session, seat, envelope } = args; + const { session, seat, envelope, now, source } = args; const { battleId, roomId } = envelope; const { clientCommandId, phase, turn, command } = envelope.payload; @@ -93,6 +96,19 @@ export function validateBattleCommand(args: { ); } + 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, diff --git a/src/server/battle/battle-session-service.ts b/src/server/battle/battle-session-service.ts index 517118c1..dd71433b 100644 --- a/src/server/battle/battle-session-service.ts +++ b/src/server/battle/battle-session-service.ts @@ -1,4 +1,5 @@ import type { RoomSeat } from '../rooms/index.js'; +import { buildRoomSnapshotPayload } from '../projection/battle-projection.js'; import { buildSeatRuntimeState, createAuthoritativeBattleState, @@ -7,7 +8,6 @@ import { validateBattleCommand } from './battle-command-service.js'; import { createActionRequestPayload, createReplacementRequestPayload, - createViewerVisibleState, projectEventsForViewer, } from './battle-event-log.js'; import { @@ -17,15 +17,21 @@ import { resolveSubmittedReplacements, } from './battle-turn-service.js'; import type { - BattleLoggedEvent, BattleCommandEnvelope, BattleCommandPhase, + BattleCommandRejectionCode, + BattleCommandSource, BattleDataResolver, + BattleFinishReason, + BattleLoggedEvent, + BattleRequestKind, + BattleRequestState, BattleServerEventEnvelope, BattleSessionCreateInput, BattleSessionMutationResult, BattleSessionRecord, BattleSessionSubmitInput, + BattleSessionResult, } from './battle-types.js'; export interface BattleSessionServiceOptions { @@ -34,6 +40,8 @@ export interface BattleSessionServiceOptions { battleIdGenerator?: () => string; } +const SEATS: RoomSeat[] = ['host', 'guest']; + export class BattleSessionService { private readonly dataResolver: BattleDataResolver; private readonly now: () => Date; @@ -78,6 +86,13 @@ export class BattleSessionService { 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, @@ -85,30 +100,26 @@ export class BattleSessionService { updatedAt: createdAt, }; - const sentAt = this.now().toISOString(); + this.issueRequestState(session, { + kind: 'choose_move_or_switch', + phase: 'awaiting_actions', + turn: session.turn, + requiredSeats: SEATS, + issuedAt: createdAt, + }); + const eventsBySeat = this.createEmptySeatEventMap(); - for (const seat of ['host', 'guest'] as const) { - this.pushEvent(eventsBySeat, seat, { + 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: { - roomStatus: session.roomStatus, - battleStatus: session.phase, - generation: session.generation, - rulesetKey: session.rulesetKey, - yourSeat: seat, - turn: session.turn, - visibleState: createViewerVisibleState(session, seat), - pendingRequest: { - kind: 'choose_move_or_switch', - deadlineMs: session.rulesetSnapshot.battlePolicy.actionTimeoutSeconds * 1000, - }, - }, + payload: buildRoomSnapshotPayload(session, seat, new Date(sentAt)), }); - this.pushEvent(eventsBySeat, seat, { + this.pushEvent(session, eventsBySeat, seat, { type: 'battle.request_action', roomId: session.roomId, battleId: session.battleId, @@ -124,11 +135,21 @@ export class BattleSessionService { 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 }); + const validation = validateBattleCommand({ session, seat, envelope, now, source }); if (!validation.ok) { - const sentAt = this.now().toISOString(); - this.pushEvent(eventsBySeat, seat, { + 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, @@ -147,8 +168,20 @@ export class BattleSessionService { session.seenClientCommandIds.push(envelope.payload.clientCommandId); - const acceptedAt = this.now().toISOString(); - this.pushEvent(eventsBySeat, seat, { + 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, @@ -164,17 +197,19 @@ export class BattleSessionService { session.updatedAt = acceptedAt; if (envelope.payload.command.type === 'forfeit') { - const finishedAt = this.now().toISOString(); + const resolvedAt = this.now().toISOString(); const resolution = resolveForfeit({ session, forfeitingSeat: seat, - recordedAt: finishedAt, + recordedAt: resolvedAt, }); - this.emitResolutionEvents(eventsBySeat, session, resolution.events, resolution.nextPhase, finishedAt); + 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.pendingCommands = {}; - session.pendingReplacementCommands = {}; - session.pendingReplacementSeats = []; session.updatedAt = finishedAt; return { session, eventsBySeat }; } @@ -192,6 +227,7 @@ export class BattleSessionService { 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); @@ -225,6 +261,7 @@ export class BattleSessionService { 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); @@ -237,6 +274,112 @@ export class BattleSessionService { 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, @@ -244,17 +387,21 @@ export class BattleSessionService { nextPhase: 'awaiting_actions' | 'awaiting_replacement' | 'finished', sentAt: string, ) { - for (const seat of ['host', 'guest'] as const) { - this.pushEvent(eventsBySeat, seat, { + 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: nextPhase === 'awaiting_actions' && session.phase === 'awaiting_actions' ? session.turn - 1 : session.turn, + turn: resolvedTurn, events: projectEventsForViewer(seat, events), - postTurnVisibleState: createViewerVisibleState(session, seat), + postTurnVisibleState: buildRoomSnapshotPayload(session, seat, new Date(sentAt)).visibleState, nextPhase, }, }); @@ -266,8 +413,8 @@ export class BattleSessionService { session: BattleSessionRecord, sentAt: string, ) { - for (const seat of ['host', 'guest'] as const) { - this.pushEvent(eventsBySeat, seat, { + for (const seat of SEATS) { + this.pushEvent(session, eventsBySeat, seat, { type: 'battle.request_action', roomId: session.roomId, battleId: session.battleId, @@ -284,7 +431,7 @@ export class BattleSessionService { sentAt: string, ) { for (const seat of session.pendingReplacementSeats) { - this.pushEvent(eventsBySeat, seat, { + this.pushEvent(session, eventsBySeat, seat, { type: 'battle.force_replacement', roomId: session.roomId, battleId: session.battleId, @@ -300,8 +447,8 @@ export class BattleSessionService { session: BattleSessionRecord, sentAt: string, ) { - for (const seat of ['host', 'guest'] as const) { - this.pushEvent(eventsBySeat, seat, { + for (const seat of SEATS) { + this.pushEvent(session, eventsBySeat, seat, { type: 'battle.ended', roomId: session.roomId, battleId: session.battleId, @@ -320,11 +467,18 @@ export class BattleSessionService { } 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 { diff --git a/src/server/battle/battle-turn-service.ts b/src/server/battle/battle-turn-service.ts index f7b18211..2e7da935 100644 --- a/src/server/battle/battle-turn-service.ts +++ b/src/server/battle/battle-turn-service.ts @@ -48,6 +48,7 @@ export function resolveSubmittedActions(args: { const result = maybeBuildResultFromBattleState(session, recordedAt); if (result) { session.phase = 'finished'; + session.pendingReplacementSeats = []; session.result = result; return { nextPhase: 'finished', @@ -67,6 +68,7 @@ export function resolveSubmittedActions(args: { }; } + session.pendingReplacementSeats = []; session.phase = 'awaiting_actions'; session.turn += 1; return { @@ -93,6 +95,7 @@ export function resolveSubmittedReplacements(args: { const result = maybeBuildResultFromBattleState(session, recordedAt); if (result) { session.phase = 'finished'; + session.pendingReplacementSeats = []; session.result = result; return { nextPhase: 'finished', @@ -117,15 +120,33 @@ export function resolveForfeit(args: { forfeitingSeat: RoomSeat; recordedAt: string; }): BattleTurnResolution { - const { session, forfeitingSeat, recordedAt } = args; + 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: 'forfeit', + reason, recordedAt, }; session.phase = 'finished'; + session.pendingReplacementSeats = []; session.result = result; return { nextPhase: 'finished', @@ -164,9 +185,7 @@ function maybeBuildResultFromBattleState( const winnerSeat: RoomSeat = hostAlive ? 'host' : 'guest'; const loserSeat: RoomSeat = winnerSeat === 'host' ? 'guest' : 'host'; - const reason: BattleFinishReason = winnerSeat === 'host' - ? 'all_opponent_pokemon_fainted' - : 'all_opponent_pokemon_fainted'; + const reason: BattleFinishReason = 'all_opponent_pokemon_fainted'; return { winnerSeat, diff --git a/src/server/battle/battle-types.ts b/src/server/battle/battle-types.ts index 371455d5..f59393f0 100644 --- a/src/server/battle/battle-types.ts +++ b/src/server/battle/battle-types.ts @@ -23,6 +23,7 @@ export type BattleSessionPhase = 'awaiting_actions' | 'awaiting_replacement' | ' 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'; @@ -314,10 +315,12 @@ export type RoomSnapshotPayload = { | { kind: 'choose_move_or_switch'; deadlineMs: number; + commandSubmitted: boolean; } | { kind: 'choose_replacement'; deadlineMs: number; + commandSubmitted: boolean; } | null; }; @@ -351,6 +354,10 @@ export interface BattleSessionRecord { pendingCommands: Partial>; pendingReplacementSeats: RoomSeat[]; pendingReplacementCommands: Partial>; + requestState: BattleRequestState | null; + timeoutState: Record; + commandLog: BattleCommandLogEntry[]; + eventLog: BattleDebugEventEntry[]; seenClientCommandIds: string[]; nextSeq: number; result: BattleSessionResult | null; @@ -368,9 +375,57 @@ 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 index 787fd28a..6f20007c 100644 --- a/src/server/battle/index.ts +++ b/src/server/battle/index.ts @@ -1,19 +1,30 @@ 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, 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/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 index 7ea2e8db..80c493c5 100644 --- a/src/server/projection/index.ts +++ b/src/server/projection/index.ts @@ -1 +1,2 @@ export { projectRoomView, RoomProjectionError, type RoomView } from './room-projection.js'; +export { buildBattleDebugView, buildRoomSnapshotPayload } from './battle-projection.js'; diff --git a/src/server/ws/pvp-ws-server.ts b/src/server/ws/pvp-ws-server.ts index 021d05cf..a39147ca 100644 --- a/src/server/ws/pvp-ws-server.ts +++ b/src/server/ws/pvp-ws-server.ts @@ -1,8 +1,13 @@ 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 { @@ -27,6 +32,7 @@ export interface PvpWsConnectInput { token: string; connectionId: string; transport: PvpWsTransport; + resume?: boolean; } export interface PvpWsConnectionSummary { @@ -35,6 +41,10 @@ export interface PvpWsConnectionSummary { battleId: string | null; } +export interface BattleTimeoutSweepResult { + processedBattles: number; +} + function cloneRoom(room: BattleRoomRecord): BattleRoomRecord { return structuredClone(room); } @@ -103,10 +113,16 @@ export class PvpWsServer { 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) { - input.transport.close(4001, 'PVP_WS_DUPLICATE_CONNECTION'); - throw createError('PVP_WS_DUPLICATE_CONNECTION'); + 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); @@ -122,11 +138,17 @@ export class PvpWsServer { 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: startedSession?.battleId ?? existingSession?.battleId ?? null, + battleId: activeSession?.battleId ?? null, }; } @@ -147,10 +169,7 @@ export class PvpWsServer { envelope: routed.envelope, }); - 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); + this.persistMutationResult(result); } disconnectClient( @@ -178,11 +197,69 @@ export class PvpWsServer { }); } + 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) { @@ -218,12 +295,10 @@ export class PvpWsServer { hostParty, guestParty, }); - this.sessionsByRoomId.set(result.session.roomId, cloneSession(result.session)); - this.registry.updateBattleIdForRoom(result.session.roomId, result.session.battleId); - this.dispatchEvents(result.session.roomId, 'host', result.eventsBySeat.host); - this.dispatchEvents(result.session.roomId, 'guest', result.eventsBySeat.guest); + this.persistMutationResult(result); - return cloneSession(result.session); + const session = this.sessionsByRoomId.get(result.session.roomId); + return session ? cloneSession(session) : cloneSession(result.session); } private syncFinishedRoom(session: BattleSessionRecord): void { @@ -320,6 +395,39 @@ export class PvpWsServer { 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, 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-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'); + }); +}); From 48837355adbbd96c3d40fef6eb522925326a6b2b Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 19:16:37 +0900 Subject: [PATCH 12/30] Expose PvP battle debug views through an operator-only route ISSUE-08 already produced internal battle debug snapshots, but operators still had no stable HTTP-facing contract to inspect a live or failed room. This change adds a minimal ops route that reuses the existing BattleDebugView shape and keeps the auth expansion intentionally tiny by adding a boolean operator flag to the HTTP auth context.\n\nThe route follows the same error-envelope style as existing PvP routes and is injected with a room-to-debug getter so it does not pull in a broader server/auth redesign prematurely. Constraint: Keep the auth change minimal and avoid redesigning the wider HTTP auth stack Rejected: Introduce a full role/permission model now | too broad for the bounded ISSUE-08 debug surface Confidence: high Scope-risk: narrow Reversibility: clean Directive: If ops auth expands beyond a boolean flag, preserve this route's error contract unless all callers are updated together Tested: node --import tsx --test test/pvp-ops-routes.test.ts; node --import tsx --test test/pvp-routes.test.ts test/pvp-room-routes.test.ts; npm run typecheck Not-tested: End-to-end HTTP wiring against a real server transport --- src/server/http/http-types.ts | 1 + src/server/http/pvp-ops-routes.ts | 80 +++++++++++++++++++++++ src/server/index.ts | 1 + test/pvp-ops-routes.test.ts | 104 ++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 src/server/http/pvp-ops-routes.ts create mode 100644 test/pvp-ops-routes.test.ts diff --git a/src/server/http/http-types.ts b/src/server/http/http-types.ts index 99069754..bd405990 100644 --- a/src/server/http/http-types.ts +++ b/src/server/http/http-types.ts @@ -1,5 +1,6 @@ export interface HttpAuthContext { playerId?: string; + operator?: boolean; } export interface HttpRequest { 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/index.ts b/src/server/index.ts index 203fb418..4283f467 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,5 @@ 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'; 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); +}); From 88d9ee4fe32f2d0a8d5b8f8f88215a593a0e22c7 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 21:47:15 +0900 Subject: [PATCH 13/30] Make PvP implementation issues executable beyond the server-only MVP The original breakdown stopped at reconnect and ops, which made the next executor slices ambiguous once the server-authoritative path was in place. This extends the issue map with explicit client integration contracts so the next work can stay thin, protocol-bound, and ordered after the server contracts stabilize. Constraint: Client-side slices must layer on top of the already-defined server authoritative contracts Rejected: Fold client integration into ISSUE-08 | would blur server stabilization and client read-model work Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep future client issues thin and protocol-bound; real socket/UI wiring should stay on top of these contracts Tested: Manual review of issue ordering, links, and todo alignment Not-tested: Automated markdown link checker not run --- .../issues/ISSUE-09-client-session-store.md | 34 +++++++++++++++++++ .../ISSUE-10-client-protocol-adapter.md | 34 +++++++++++++++++++ docs/pvp/implementation/issues/README.md | 6 ++++ docs/pvp/implementation/todo-breakdown.md | 2 ++ 4 files changed, 76 insertions(+) create mode 100644 docs/pvp/implementation/issues/ISSUE-09-client-session-store.md create mode 100644 docs/pvp/implementation/issues/ISSUE-10-client-protocol-adapter.md 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/README.md b/docs/pvp/implementation/issues/README.md index e06dbfd0..74304e0e 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -23,6 +23,8 @@ 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) ## 왜 이 순서인가 @@ -31,6 +33,9 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 따라서 초기 착수는 반드시 **정책 계층 → 검증 계층 → 등록 surface** 순으로 간다. +서버 권한 배틀과 재접속 정책이 먼저 완성된 뒤에야, Claude Code / TUI 클라이언트가 신뢰할 수 있는 읽기 모델을 얹을 수 있다. +그래서 클라이언트 측 구현은 **서버 authoritative contract 고정 이후**에 별도 이슈로 분리한다. + ## 첫 실행 대상 현재 첫 구현 대상은 [ISSUE-01 · ruleset / 정책 기반 계층](./ISSUE-01-ruleset-foundation.md)이다. @@ -48,6 +53,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | B. 룸까지 되는 상태 | ISSUE-04, ISSUE-05 | | C. 배틀 되는 상태 | ISSUE-06, ISSUE-07 | | D. 실제 사용 가능한 상태 | ISSUE-08 | +| E. 클라이언트 통합 시작 상태 | ISSUE-09, ISSUE-10 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index db292f88..d65e8bca 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -118,6 +118,8 @@ - [ ] move/switch/replacement 입력 UX 구현 - [ ] command accepted 상태 반영 - [ ] turn resolved 이벤트 렌더링 +- [ ] 클라이언트 세션 스토어(`src/pvp/session-store.ts`) 구현 +- [ ] 클라이언트 프로토콜 어댑터(`src/pvp/client-protocol.ts`) 구현 ### 주의사항 - 클라이언트는 결과 계산 금지 From d4cc2eaca3bc766ed6eb1a1da1b401f2e1341d27 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 21:47:46 +0900 Subject: [PATCH 14/30] Give PvP clients a pure authoritative adapter before wiring any socket UI This adds the first client-side read-model slice on top of the server-owned PvP protocol. The session store projects authoritative battle events into a local state shape, while the protocol adapter handles transport envelopes and keeps battle calculation off the client. Keeping this layer pure makes later websocket and Claude Code integration thinner and safer. Constraint: The initial client slice must not depend on a websocket implementation or perform any battle resolution locally Rejected: Wire socket/UI code directly into the first client slice | would mix protocol, state, and presentation concerns too early Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep server battle calculation authoritative; future client work may send commands but must not compute battle outcomes Tested: node --import tsx --test test/pvp-session-store.test.ts test/pvp-client-protocol.test.ts; npm run typecheck; git diff --check; npm test Not-tested: Live websocket integration against a running PvP server --- src/pvp/client-protocol.ts | 116 ++++++++ src/pvp/index.ts | 25 ++ src/pvp/session-store.ts | 471 +++++++++++++++++++++++++++++++ test/pvp-client-protocol.test.ts | 187 ++++++++++++ test/pvp-session-store.test.ts | 411 +++++++++++++++++++++++++++ 5 files changed, 1210 insertions(+) create mode 100644 src/pvp/client-protocol.ts create mode 100644 src/pvp/index.ts create mode 100644 src/pvp/session-store.ts create mode 100644 test/pvp-client-protocol.test.ts create mode 100644 test/pvp-session-store.test.ts 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/index.ts b/src/pvp/index.ts new file mode 100644 index 00000000..cd1a12d0 --- /dev/null +++ b/src/pvp/index.ts @@ -0,0 +1,25 @@ +export { + applyPvpTransportEnvelope, + createPvpClientCommand, + createPvpClientState, + type ApplyPvpTransportEnvelopeOptions, + type AppliedPvpTransportEnvelope, + type CreatedPvpClientCommand, + type PvpClientInboundEnvelope, + type PvpClientOutboundEnvelope, + type PvpClientState, +} from './client-protocol.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/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/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-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, + ); + }); +}); From 1c289ba85e43b649fa9bc4fb96f1d047933d7dd7 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 21:55:16 +0900 Subject: [PATCH 15/30] Make websocket client transport the next explicit PvP execution slice The docs already pinned the server-authoritative battle contract and the first two client layers (session store and protocol adapter), but the next transport-facing step was still implicit. This commit adds ISSUE-11 for the websocket client connector and wires it into the implementation index and TODO flow so the next executor task is unambiguous. Constraint: PvP client transport must stay environment-independent across Claude Code and TUI Rejected: Implement websocket code immediately | client transport boundary should be specified before runtime-specific edits Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep websocket work layered on top of session-store and client-protocol; do not collapse transport and UI concerns Tested: Relative markdown links for changed docs resolved; git diff --check Not-tested: Runtime websocket behavior (docs-only change) --- docs/pvp/implementation/README.md | 1 + .../ISSUE-11-websocket-client-connector.md | 43 +++++++++++++++++++ docs/pvp/implementation/issues/README.md | 3 +- docs/pvp/implementation/todo-breakdown.md | 2 + 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 docs/pvp/implementation/issues/ISSUE-11-websocket-client-connector.md diff --git a/docs/pvp/implementation/README.md b/docs/pvp/implementation/README.md index 5faeee45..fcf9c954 100644 --- a/docs/pvp/implementation/README.md +++ b/docs/pvp/implementation/README.md @@ -25,3 +25,4 @@ - 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-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/README.md b/docs/pvp/implementation/issues/README.md index 74304e0e..903b00d5 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -25,6 +25,7 @@ 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) ## 왜 이 순서인가 @@ -53,7 +54,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | B. 룸까지 되는 상태 | ISSUE-04, ISSUE-05 | | C. 배틀 되는 상태 | ISSUE-06, ISSUE-07 | | D. 실제 사용 가능한 상태 | ISSUE-08 | -| E. 클라이언트 통합 시작 상태 | ISSUE-09, ISSUE-10 | +| E. 클라이언트 통합 시작 상태 | ISSUE-09, ISSUE-10, ISSUE-11 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index d65e8bca..f39d6086 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -120,6 +120,7 @@ - [ ] turn resolved 이벤트 렌더링 - [ ] 클라이언트 세션 스토어(`src/pvp/session-store.ts`) 구현 - [ ] 클라이언트 프로토콜 어댑터(`src/pvp/client-protocol.ts`) 구현 +- [ ] WebSocket 클라이언트 커넥터(`src/pvp/websocket-client.ts`) 구현 ### 주의사항 - 클라이언트는 결과 계산 금지 @@ -140,6 +141,7 @@ - [ ] 끊김 후 재접속 UX 추가 - [ ] 이미 제출한 명령 표시 처리 - [ ] 진행 중 턴 상태 복원 처리 +- [ ] WebSocket connector 위에서 reconnect/backoff 정책 정리 --- From b6648a13c94e9a3c1507401a1132ac76cde95aaa Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 22:09:53 +0900 Subject: [PATCH 16/30] Let Claude Code clients join authoritative PvP rooms over websocket The PvP reducer layer was already pure and testable, but there was no transport adapter that could actually connect a Claude Code client to the realtime battle server. This adds a socket-agnostic websocket client that owns transport state, serializes outbound battle commands, answers ping/pong frames, and feeds inbound envelopes into the existing protocol/session reducers. The public PvP barrel now exports the transport API, and focused tests lock the connector contract before we wire a live server transport. Constraint: Multiplayer battles must remain server-authoritative and accept only client intents over the wire Constraint: The client transport must stay reusable across Claude Code/TUI runtimes instead of binding to browser globals Rejected: Wait for the full server transport stack first | would leave ISSUE-11 blocked behind later integration work Rejected: Depend directly on global WebSocket | would make the connector harder to test and reuse in non-browser environments Confidence: high Scope-risk: narrow Directive: Keep websocket reconnection/transport concerns separate from the pure protocol and session reducers Tested: npm test; npm run typecheck; git diff --check Not-tested: Live websocket integration against a running PvP server --- src/pvp/index.ts | 16 ++ src/pvp/websocket-client.ts | 412 ++++++++++++++++++++++++++++++ test/pvp-websocket-client.test.ts | 368 ++++++++++++++++++++++++++ 3 files changed, 796 insertions(+) create mode 100644 src/pvp/websocket-client.ts create mode 100644 test/pvp-websocket-client.test.ts diff --git a/src/pvp/index.ts b/src/pvp/index.ts index cd1a12d0..fd46a5fa 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -9,6 +9,22 @@ export { 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 { applyPvpServerEvent, createBattleCommandEnvelope, 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/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); + }); +}); From 8ed759d37c4c5aec59f407d565a63f6d9eb2b4bc Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 22:22:30 +0900 Subject: [PATCH 17/30] Keep PvP clients recoverable after transport drops The websocket transport layer was already in place, but higher-level clients still lacked a deterministic way to distinguish manual exits from accidental disconnects and to schedule reconnect attempts without pulling battle/session responsibilities into the transport module. This adds a thin reconnect controller above the websocket client, exports it through the PvP surface, and documents ISSUE-12 as the next client-stability slice. The controller exposes reconnect state for UI consumers while leaving authoritative recovery to server snapshots. Constraint: Reconnect orchestration must remain environment-agnostic for Claude Code and TUI clients Constraint: Manual disconnects must never schedule implicit reconnect attempts Rejected: Folding backoff state into websocket-client | would blur transport and orchestration responsibilities Rejected: Reconnect directly inside session-store | session-store should stay focused on authoritative battle state application Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep reconnect policy separate from snapshot hydration so future UI wiring can evolve without mutating transport semantics Tested: node --import tsx --test test/pvp-websocket-client.test.ts test/pvp-reconnect-controller.test.ts Tested: npm run typecheck Tested: npm test Tested: git diff --check Not-tested: Real server reconnect UX wiring in Claude Code/TUI entrypoints --- .../ISSUE-12-client-reconnect-controller.md | 42 +++ docs/pvp/implementation/issues/README.md | 2 + docs/pvp/implementation/todo-breakdown.md | 11 +- src/pvp/index.ts | 12 + src/pvp/reconnect-controller.ts | 300 ++++++++++++++++++ test/pvp-reconnect-controller.test.ts | 285 +++++++++++++++++ 6 files changed, 649 insertions(+), 3 deletions(-) create mode 100644 docs/pvp/implementation/issues/ISSUE-12-client-reconnect-controller.md create mode 100644 src/pvp/reconnect-controller.ts create mode 100644 test/pvp-reconnect-controller.test.ts 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/README.md b/docs/pvp/implementation/issues/README.md index 903b00d5..4f0df514 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -26,6 +26,7 @@ 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) ## 왜 이 순서인가 @@ -55,6 +56,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | C. 배틀 되는 상태 | ISSUE-06, ISSUE-07 | | D. 실제 사용 가능한 상태 | ISSUE-08 | | E. 클라이언트 통합 시작 상태 | ISSUE-09, ISSUE-10, ISSUE-11 | +| F. 실제 접속 안정화 시작 상태 | ISSUE-12 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index f39d6086..401543ef 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -118,9 +118,9 @@ - [ ] move/switch/replacement 입력 UX 구현 - [ ] command accepted 상태 반영 - [ ] turn resolved 이벤트 렌더링 -- [ ] 클라이언트 세션 스토어(`src/pvp/session-store.ts`) 구현 -- [ ] 클라이언트 프로토콜 어댑터(`src/pvp/client-protocol.ts`) 구현 -- [ ] WebSocket 클라이언트 커넥터(`src/pvp/websocket-client.ts`) 구현 +- [x] 클라이언트 세션 스토어(`src/pvp/session-store.ts`) 구현 +- [x] 클라이언트 프로토콜 어댑터(`src/pvp/client-protocol.ts`) 구현 +- [x] WebSocket 클라이언트 커넥터(`src/pvp/websocket-client.ts`) 구현 ### 주의사항 - 클라이언트는 결과 계산 금지 @@ -143,6 +143,11 @@ - [ ] 진행 중 턴 상태 복원 처리 - [ ] WebSocket connector 위에서 reconnect/backoff 정책 정리 +### 실행 이슈 메모 + +- Phase 3 클라이언트 기초는 ISSUE-09 ~ ISSUE-11로 완료 +- 다음 구현 단위는 [ISSUE-12 · 클라이언트 재접속 / backoff 컨트롤러](./issues/ISSUE-12-client-reconnect-controller.md) + --- ## Phase 5. 운영/밸런스 후속 diff --git a/src/pvp/index.ts b/src/pvp/index.ts index fd46a5fa..e6d1a980 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -25,6 +25,18 @@ export { 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 { applyPvpServerEvent, createBattleCommandEnvelope, 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/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); + }); +}); From e88922daf8531593a058f20f52537dd88849661f Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 22:34:08 +0900 Subject: [PATCH 18/30] Unify PvP client transport and session state behind one entrypoint Upper-layer consumers should not have to wire websocket, reconnect, and protocol state separately. This change introduces a session-level facade that composes those layers into one subscription surface, keeps request lock state derived in one place, and documents the new implementation slice for the PvP roadmap. Constraint: PvP battle resolution remains server-authoritative and must not move into the client facade Rejected: Wire battle-tui directly to websocket/reconnect primitives | would leak transport concerns into every caller Confidence: high Scope-risk: narrow Directive: Keep session-client as orchestration/facade only; do not move battle rules or rendering policy into it Tested: node --import tsx --test test/pvp-session-client.test.ts (4/4 pass) Tested: npm run typecheck Tested: npm test (1180/1180 pass) Tested: git diff --check Not-tested: battle-tui/CLI integration wiring for action-request UX --- .../ISSUE-13-session-client-orchestrator.md | 50 +++ docs/pvp/implementation/issues/README.md | 2 + docs/pvp/implementation/todo-breakdown.md | 7 +- src/pvp/index.ts | 9 + src/pvp/session-client.ts | 184 +++++++++ test/pvp-session-client.test.ts | 354 ++++++++++++++++++ 6 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 docs/pvp/implementation/issues/ISSUE-13-session-client-orchestrator.md create mode 100644 src/pvp/session-client.ts create mode 100644 test/pvp-session-client.test.ts 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/README.md b/docs/pvp/implementation/issues/README.md index 4f0df514..4e8fb00c 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -27,6 +27,7 @@ 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) ## 왜 이 순서인가 @@ -57,6 +58,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | D. 실제 사용 가능한 상태 | ISSUE-08 | | E. 클라이언트 통합 시작 상태 | ISSUE-09, ISSUE-10, ISSUE-11 | | F. 실제 접속 안정화 시작 상태 | ISSUE-12 | +| G. 상위 클라이언트 진입점 정리 상태 | ISSUE-13 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index 401543ef..b5b345e2 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -141,12 +141,15 @@ - [ ] 끊김 후 재접속 UX 추가 - [ ] 이미 제출한 명령 표시 처리 - [ ] 진행 중 턴 상태 복원 처리 -- [ ] WebSocket connector 위에서 reconnect/backoff 정책 정리 +- [x] WebSocket connector 위에서 reconnect/backoff 정책 정리 +- [x] 상위 PvP session client orchestration 레이어 추가 ### 실행 이슈 메모 - Phase 3 클라이언트 기초는 ISSUE-09 ~ ISSUE-11로 완료 -- 다음 구현 단위는 [ISSUE-12 · 클라이언트 재접속 / backoff 컨트롤러](./issues/ISSUE-12-client-reconnect-controller.md) +- Phase 4 재접속 controller는 ISSUE-12로 완료 +- 상위 PvP session client facade는 ISSUE-13으로 완료 +- 다음 구현 단위는 PvP action request 렌더링 / 입력 UX adapter 이슈로 분리 예정 --- diff --git a/src/pvp/index.ts b/src/pvp/index.ts index e6d1a980..5385110f 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -37,6 +37,15 @@ export { 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 { applyPvpServerEvent, createBattleCommandEnvelope, 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/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'); + }); +}); From 9f157cb48c4848d2564210a81037cd30d3bb1f53 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 22:46:01 +0900 Subject: [PATCH 19/30] Make authoritative PvP requests directly renderable by terminal clients The session client already centralized authoritative pending-request state, but higher-level surfaces still had to understand raw request payloads. This slice adds a presentation-only adapter that normalizes move, switch, replacement, and forfeit choices into deterministic menu sections with lock/submission UX state. Constraint: Must preserve server-authoritative command shapes without leaking battle resolution into the client adapter Rejected: Let each UI read raw pendingRequest directly | would duplicate ordering, labels, and lock semantics across clients Rejected: Wire full interactive TUI input loop in this slice | too much coupling before turn-result rendering lands Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep action-request-view presentation-only; transport, reconnect, and battle rules stay below this layer Tested: node --import tsx --test test/pvp-action-request-view.test.ts Tested: npm run typecheck Tested: npm test Tested: git diff --check Not-tested: Manual battle TUI integration with live PvP room input --- .../issues/ISSUE-14-action-request-view.md | 42 +++ docs/pvp/implementation/issues/README.md | 1 + docs/pvp/implementation/todo-breakdown.md | 7 +- src/pvp/action-request-view.ts | 314 ++++++++++++++++++ src/pvp/index.ts | 8 + test/pvp-action-request-view.test.ts | 221 ++++++++++++ 6 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 docs/pvp/implementation/issues/ISSUE-14-action-request-view.md create mode 100644 src/pvp/action-request-view.ts create mode 100644 test/pvp-action-request-view.test.ts 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/README.md b/docs/pvp/implementation/issues/README.md index 4e8fb00c..60c08863 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -28,6 +28,7 @@ 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) ## 왜 이 순서인가 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index b5b345e2..426e0e00 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -114,8 +114,8 @@ - [ ] public / private payload 분리 저장 규칙 추가 ### 클라이언트 작업 -- [ ] action request 렌더링 -- [ ] move/switch/replacement 입력 UX 구현 +- [x] action request 렌더링 +- [x] move/switch/replacement 입력 UX adapter 구현 - [ ] command accepted 상태 반영 - [ ] turn resolved 이벤트 렌더링 - [x] 클라이언트 세션 스토어(`src/pvp/session-store.ts`) 구현 @@ -149,7 +149,8 @@ - Phase 3 클라이언트 기초는 ISSUE-09 ~ ISSUE-11로 완료 - Phase 4 재접속 controller는 ISSUE-12로 완료 - 상위 PvP session client facade는 ISSUE-13으로 완료 -- 다음 구현 단위는 PvP action request 렌더링 / 입력 UX adapter 이슈로 분리 예정 +- PvP action request 렌더링 / 입력 UX adapter는 ISSUE-14로 진행 +- 다음 구현 단위는 turn resolved 이벤트 렌더링 이슈로 분리 예정 --- 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/index.ts b/src/pvp/index.ts index 5385110f..568e5112 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -46,6 +46,14 @@ export { type PvpSessionClientStateListener, type SendPvpSessionBattleCommandResult, } from './session-client.js'; +export { + createPvpActionRequestView, + createPvpActionRequestViewFromPendingRequest, + type CreatePvpActionRequestViewOptions, + type PvpActionRequestMenuEntry, + type PvpActionRequestMenuSection, + type PvpActionRequestView, +} from './action-request-view.js'; export { applyPvpServerEvent, createBattleCommandEnvelope, 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); + }); +}); From ec15e17aafd5a3772100f585a90985a43666a73a Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 22:59:46 +0900 Subject: [PATCH 20/30] Make turn-resolved payloads directly renderable in PvP clients The session-client consumer should be able to build turn-result UX from one adapter surface, so this adds a payload-first read model plus a session-backed summary fallback. Constraint: Session state does not currently retain raw turn_resolved events for reconnect-time replay Rejected: Expand session-store in this slice | outside assigned file ownership and broader than the presentation-only adapter Confidence: high Scope-risk: narrow Directive: Keep this module presentation-only; do not move battle calculation or transport concerns into it Tested: node --import tsx --test test/pvp-turn-result-view.test.ts; npm run typecheck Not-tested: End-to-end TUI/CLI integration rendering against a live session --- .../issues/ISSUE-15-turn-result-view.md | 57 +++ docs/pvp/implementation/issues/README.md | 2 + docs/pvp/implementation/todo-breakdown.md | 7 +- src/pvp/index.ts | 12 + src/pvp/turn-result-view.ts | 401 ++++++++++++++++++ test/pvp-turn-result-view.test.ts | 253 +++++++++++ 6 files changed, 729 insertions(+), 3 deletions(-) create mode 100644 docs/pvp/implementation/issues/ISSUE-15-turn-result-view.md create mode 100644 src/pvp/turn-result-view.ts create mode 100644 test/pvp-turn-result-view.test.ts 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/README.md b/docs/pvp/implementation/issues/README.md index 60c08863..f0664c53 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -29,6 +29,7 @@ 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) ## 왜 이 순서인가 @@ -60,6 +61,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | E. 클라이언트 통합 시작 상태 | ISSUE-09, ISSUE-10, ISSUE-11 | | F. 실제 접속 안정화 시작 상태 | ISSUE-12 | | G. 상위 클라이언트 진입점 정리 상태 | ISSUE-13 | +| H. 턴 결과 렌더링 진입점 정리 상태 | ISSUE-14, ISSUE-15 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index 426e0e00..dd139197 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -117,7 +117,7 @@ - [x] action request 렌더링 - [x] move/switch/replacement 입력 UX adapter 구현 - [ ] command accepted 상태 반영 -- [ ] turn resolved 이벤트 렌더링 +- [x] turn resolved 이벤트 렌더링 - [x] 클라이언트 세션 스토어(`src/pvp/session-store.ts`) 구현 - [x] 클라이언트 프로토콜 어댑터(`src/pvp/client-protocol.ts`) 구현 - [x] WebSocket 클라이언트 커넥터(`src/pvp/websocket-client.ts`) 구현 @@ -149,8 +149,9 @@ - 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 이벤트 렌더링 이슈로 분리 예정 +- PvP action request 렌더링 / 입력 UX adapter는 ISSUE-14로 완료 +- turn resolved 결과 로그 / summary adapter는 ISSUE-15로 완료 +- session-store에 last resolved payload를 보존하면 향후 reconnect 뒤 full log 재생 UX까지 확장 가능 --- diff --git a/src/pvp/index.ts b/src/pvp/index.ts index 568e5112..dcf35321 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -54,6 +54,18 @@ export { type PvpActionRequestMenuSection, type PvpActionRequestView, } from './action-request-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 { applyPvpServerEvent, createBattleCommandEnvelope, 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/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); + }); +}); From 8c4830326b13f4249dd0f455a704ff27a5c41c70 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 23:17:09 +0900 Subject: [PATCH 21/30] Make submitted PvP commands readable from session state This adds a presentation adapter that turns existing session-client and\nsession-store state into a deterministic Korean read model for\nsubmitted-command UX. Consumers can now read acceptance, lock, summary,\nand rejection state without reinterpreting pendingCommand and\nrequest.commandSubmitted on their own.\n\nConstraint: This slice must stay adapter-first and avoid transport or store behavior changes\nRejected: Update session-store/session-client contracts directly | outside the owned scope for ISSUE-16\nConfidence: high\nScope-risk: narrow\nDirective: Extend this read model before changing pendingCommand semantics in consumers\nTested: node --import tsx --test test/pvp-command-status-view.test.ts\nTested: npm run typecheck\nTested: lsp diagnostics on modified TypeScript files --- .../issues/ISSUE-16-command-status-view.md | 49 ++++ docs/pvp/implementation/issues/README.md | 2 + docs/pvp/implementation/todo-breakdown.md | 3 +- src/pvp/command-status-view.ts | 226 ++++++++++++++++++ src/pvp/index.ts | 8 + test/pvp-command-status-view.test.ts | 210 ++++++++++++++++ 6 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 docs/pvp/implementation/issues/ISSUE-16-command-status-view.md create mode 100644 src/pvp/command-status-view.ts create mode 100644 test/pvp-command-status-view.test.ts 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/README.md b/docs/pvp/implementation/issues/README.md index f0664c53..d34fd839 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -30,6 +30,7 @@ 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) ## 왜 이 순서인가 @@ -62,6 +63,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | F. 실제 접속 안정화 시작 상태 | ISSUE-12 | | G. 상위 클라이언트 진입점 정리 상태 | ISSUE-13 | | H. 턴 결과 렌더링 진입점 정리 상태 | ISSUE-14, ISSUE-15 | +| I. 제출 상태/접수 상태 읽기 모델 정리 상태 | ISSUE-16 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index dd139197..b8c231fa 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -116,7 +116,7 @@ ### 클라이언트 작업 - [x] action request 렌더링 - [x] move/switch/replacement 입력 UX adapter 구현 -- [ ] command accepted 상태 반영 +- [x] command accepted 상태 반영 - [x] turn resolved 이벤트 렌더링 - [x] 클라이언트 세션 스토어(`src/pvp/session-store.ts`) 구현 - [x] 클라이언트 프로토콜 어댑터(`src/pvp/client-protocol.ts`) 구현 @@ -151,6 +151,7 @@ - 상위 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-store에 last resolved payload를 보존하면 향후 reconnect 뒤 full log 재생 UX까지 확장 가능 --- 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 index dcf35321..3cd4e694 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -54,6 +54,14 @@ export { 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, 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 세부 정보는 없습니다.', + ); + }); +}); From 211585697f5ecd6ebea7a9094831a8ca31e9c246 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 23:27:00 +0900 Subject: [PATCH 22/30] Give PvP clients one deterministic session screen model Issue 17 adds a session-level read model that bundles transport status, session metadata, action requests, command submission state, and turn results into one deterministic payload for higher-level consumers. The adapter stays outside the store/session-client contracts, so battle-tui and CLI integration can happen in a later slice without forcing those layers to re-interpret protocol state again. Constraint: Keep session-store, session-client, and existing read model contracts stable while adding the new adapter Rejected: Fold the screen view into session-client state | would blur transport/store concerns and broaden public contract changes Rejected: Build ANSI layout strings now | renderer coupling is intentionally deferred to a later slice Confidence: high Scope-risk: narrow Directive: Keep this module a pure data adapter; add renderer/layout concerns in follow-up slices instead of growing presentation logic here Tested: node --import tsx --test test/pvp-session-screen-view.test.ts Tested: node --import tsx --test test/pvp-action-request-view.test.ts test/pvp-command-status-view.test.ts test/pvp-turn-result-view.test.ts test/pvp-session-screen-view.test.ts Tested: npm run typecheck --- .../issues/ISSUE-17-session-screen-view.md | 56 ++++ docs/pvp/implementation/issues/README.md | 2 + docs/pvp/implementation/todo-breakdown.md | 3 + src/pvp/index.ts | 7 + src/pvp/session-screen-view.ts | 247 ++++++++++++++++++ test/pvp-session-screen-view.test.ts | 239 +++++++++++++++++ 6 files changed, 554 insertions(+) create mode 100644 docs/pvp/implementation/issues/ISSUE-17-session-screen-view.md create mode 100644 src/pvp/session-screen-view.ts create mode 100644 test/pvp-session-screen-view.test.ts 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/README.md b/docs/pvp/implementation/issues/README.md index d34fd839..86ef7828 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -31,6 +31,7 @@ 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) ## 왜 이 순서인가 @@ -64,6 +65,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | G. 상위 클라이언트 진입점 정리 상태 | ISSUE-13 | | H. 턴 결과 렌더링 진입점 정리 상태 | ISSUE-14, ISSUE-15 | | I. 제출 상태/접수 상태 읽기 모델 정리 상태 | ISSUE-16 | +| J. 세션 단위 상위 화면 view model 정리 상태 | ISSUE-17 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index b8c231fa..e38df507 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -141,6 +141,7 @@ - [ ] 끊김 후 재접속 UX 추가 - [ ] 이미 제출한 명령 표시 처리 - [ ] 진행 중 턴 상태 복원 처리 +- [x] session-level PvP screen view model 정리 - [x] WebSocket connector 위에서 reconnect/backoff 정책 정리 - [x] 상위 PvP session client orchestration 레이어 추가 @@ -152,7 +153,9 @@ - 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로 완료 - session-store에 last resolved payload를 보존하면 향후 reconnect 뒤 full log 재생 UX까지 확장 가능 +- ISSUE-17 이후 상위 consumer는 session snapshot 하나로 transport/session/request/command/result를 함께 소비할 수 있으며, 다음 슬라이스는 battle-tui/cli에 이 read model을 붙이는 작업으로 바로 이어진다. --- diff --git a/src/pvp/index.ts b/src/pvp/index.ts index 3cd4e694..a67aed53 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -74,6 +74,13 @@ export { 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 { applyPvpServerEvent, createBattleCommandEnvelope, 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/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'); + }); +}); From 51ec31af91b83796a31f4716e5e54f039b2536f1 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sat, 11 Apr 2026 23:38:03 +0900 Subject: [PATCH 23/30] Give PvP clients a deterministic terminal screen surface ISSUE-17 stopped at a session-level read model, so this change adds the first plain-text consumer layer that higher-level clients can print immediately without waiting for battle-tui or Claude Code loop integration. Constraint: Must stay inside src/pvp and avoid battle-tui, WebSocket, stdin, or other I/O coupling Rejected: Wire renderer directly into battle-tui/game-loop | runtime integration belongs to a later issue and would blur the read-model boundary Confidence: high Scope-risk: narrow Directive: Keep this renderer deterministic and pure; add ANSI or live input wiring only in a dedicated integration issue Tested: node --import tsx --test test/pvp-session-screen-renderer.test.ts Tested: node --import tsx --test test/pvp-action-request-view.test.ts test/pvp-command-status-view.test.ts test/pvp-turn-result-view.test.ts test/pvp-session-screen-view.test.ts test/pvp-session-screen-renderer.test.ts Tested: npm run typecheck Tested: git diff --check Not-tested: battle-tui/game-loop integration, Claude Code command loop integration --- .../ISSUE-18-session-screen-renderer.md | 52 ++++ docs/pvp/implementation/issues/README.md | 2 + docs/pvp/implementation/todo-breakdown.md | 5 +- src/pvp/index.ts | 4 + src/pvp/session-screen-renderer.ts | 249 ++++++++++++++++++ test/pvp-session-screen-renderer.test.ts | 234 ++++++++++++++++ 6 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 docs/pvp/implementation/issues/ISSUE-18-session-screen-renderer.md create mode 100644 src/pvp/session-screen-renderer.ts create mode 100644 test/pvp-session-screen-renderer.test.ts 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/README.md b/docs/pvp/implementation/issues/README.md index 86ef7828..e86b2b9c 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -32,6 +32,7 @@ 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) ## 왜 이 순서인가 @@ -66,6 +67,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | H. 턴 결과 렌더링 진입점 정리 상태 | ISSUE-14, ISSUE-15 | | I. 제출 상태/접수 상태 읽기 모델 정리 상태 | ISSUE-16 | | J. 세션 단위 상위 화면 view model 정리 상태 | ISSUE-17 | +| K. 상위 consumer용 deterministic terminal renderer 정리 상태 | ISSUE-18 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index e38df507..ae2e8f01 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -142,6 +142,7 @@ - [ ] 이미 제출한 명령 표시 처리 - [ ] 진행 중 턴 상태 복원 처리 - [x] session-level PvP screen view model 정리 +- [x] deterministic session terminal renderer 추가 - [x] WebSocket connector 위에서 reconnect/backoff 정책 정리 - [x] 상위 PvP session client orchestration 레이어 추가 @@ -154,8 +155,10 @@ - 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로 완료 - session-store에 last resolved payload를 보존하면 향후 reconnect 뒤 full log 재생 UX까지 확장 가능 -- ISSUE-17 이후 상위 consumer는 session snapshot 하나로 transport/session/request/command/result를 함께 소비할 수 있으며, 다음 슬라이스는 battle-tui/cli에 이 read model을 붙이는 작업으로 바로 이어진다. +- ISSUE-17 이후 상위 consumer는 session snapshot 하나로 transport/session/request/command/result를 함께 소비할 수 있으며, ISSUE-18은 이를 plain-text terminal layout으로 고정한다. +- ISSUE-18 이후 다음 슬라이스는 battle-tui/cli에 이 deterministic renderer 또는 동일 contract를 붙이는 작업으로 바로 이어진다. --- diff --git a/src/pvp/index.ts b/src/pvp/index.ts index a67aed53..774d5e7e 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -81,6 +81,10 @@ export { type PvpSessionScreenTransportSummary, type PvpSessionScreenView, } from './session-screen-view.js'; +export { + renderPvpSessionClientScreen, + renderPvpSessionScreen, +} from './session-screen-renderer.js'; export { applyPvpServerEvent, createBattleCommandEnvelope, 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/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/); + }); +}); From 591ca5bbb317c0f843391b36eaf6212c867db479 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sun, 12 Apr 2026 00:02:10 +0900 Subject: [PATCH 24/30] Expose a deterministic terminal bridge for PvP session commands This adds a small controller layer between the PvP session client and terminal-facing consumers so higher-level entrypoints can render the current plain-text screen, resolve menu tokens back to authoritative battle commands, and submit those tokens through a deterministic result contract without touching stdin, game-loop, or transport lifecycle code. Constraint: Must preserve existing PvP client/view/store contracts Constraint: Must not couple this step to battle-tui or WebSocket lifecycle changes Rejected: Push token parsing into battle-tui directly | would bypass the shared terminal contract Rejected: Add new transport abstractions here | broader scope than ISSUE-19 Confidence: high Scope-risk: narrow Directive: Keep input-token authority anchored to createPvpActionRequestView rather than duplicating token logic elsewhere Tested: session terminal controller tests; related PvP renderer/request/session-client tests; npm test; npm run typecheck Not-tested: live battle-tui stdin loop integration against a real PvP room --- .../ISSUE-19-session-terminal-controller.md | 71 +++++ docs/pvp/implementation/issues/README.md | 2 + docs/pvp/implementation/todo-breakdown.md | 4 +- src/pvp/index.ts | 18 ++ src/pvp/session-terminal-controller.ts | 280 +++++++++++++++++ test/pvp-session-terminal-controller.test.ts | 297 ++++++++++++++++++ 6 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 docs/pvp/implementation/issues/ISSUE-19-session-terminal-controller.md create mode 100644 src/pvp/session-terminal-controller.ts create mode 100644 test/pvp-session-terminal-controller.test.ts 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/README.md b/docs/pvp/implementation/issues/README.md index e86b2b9c..98ea23a6 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -33,6 +33,7 @@ 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) ## 왜 이 순서인가 @@ -68,6 +69,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | 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 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index ae2e8f01..9a8cf89b 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -156,9 +156,11 @@ - 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 또는 동일 contract를 붙이는 작업으로 바로 이어진다. +- 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 정책만 얹으면 된다. --- diff --git a/src/pvp/index.ts b/src/pvp/index.ts index 774d5e7e..2c1ba976 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -85,6 +85,24 @@ 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 { applyPvpServerEvent, createBattleCommandEnvelope, 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/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/); + }); +}); From a50d9c64f0b3ba6b8885a5210846de83f1a7d19a Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sun, 12 Apr 2026 00:21:53 +0900 Subject: [PATCH 25/30] Give live PvP clients one stable terminal session loop The deterministic terminal controller from ISSUE-19 already knew how to turn session state into a screen and accept tokenized commands, but upper layers still needed to manually juggle subscriptions, submit results, and repaint triggers. This adds a small session-terminal runner that owns that orchestration boundary so future CLI/TUI entry points can focus on transport bootstrap and stdin wiring instead of state fan-out. The runner subscribes to the live session client, keeps one current snapshot/screen/token view, emits revisioned updates to listeners, and re-syncs the submit result against the latest post-submit snapshot. The index surface and implementation docs are updated so later slices can build on one explicit contract. Constraint: Initial PvP client work needs a deterministic non-ANSI orchestration layer before raw stdin or websocket UX wiring Rejected: Attach raw stdin/readline directly in this slice | would couple orchestration tests to process IO too early Rejected: Recompute screen logic inside a second runner-specific renderer | controller already defines the deterministic terminal contract Confidence: high Scope-risk: narrow Directive: Keep this runner transport-agnostic; CLI/TUI layers should own connect/join lifecycle and terminal IO policy Tested: git diff --check Tested: npm run typecheck Tested: npm test (1215 pass) Not-tested: Real src/cli or src/battle-tui integration against live room join flow --- .../ISSUE-20-session-terminal-runner.md | 73 +++++ docs/pvp/implementation/issues/README.md | 2 + docs/pvp/implementation/todo-breakdown.md | 1 + src/pvp/index.ts | 10 + src/pvp/session-terminal-runner.ts | 206 +++++++++++++ test/pvp-session-terminal-runner.test.ts | 281 ++++++++++++++++++ 6 files changed, 573 insertions(+) create mode 100644 docs/pvp/implementation/issues/ISSUE-20-session-terminal-runner.md create mode 100644 src/pvp/session-terminal-runner.ts create mode 100644 test/pvp-session-terminal-runner.test.ts 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/README.md b/docs/pvp/implementation/issues/README.md index 98ea23a6..ead026ef 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -34,6 +34,7 @@ 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) ## 왜 이 순서인가 @@ -70,6 +71,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | 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 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index 9a8cf89b..370e2c75 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -161,6 +161,7 @@ - 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만 추가로 붙인다. --- diff --git a/src/pvp/index.ts b/src/pvp/index.ts index 2c1ba976..3b242352 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -103,6 +103,16 @@ export { 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 { applyPvpServerEvent, createBattleCommandEnvelope, 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/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); + }); +}); From d412284f70151ad76e0c58425f19afe56a0d4c21 Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sun, 12 Apr 2026 00:36:52 +0900 Subject: [PATCH 26/30] Give live PvP sessions a testable CLI bootstrap boundary ISSUE-21 extracts the start/stop/input/output orchestration layer above the session runner so live terminal flows can grow without burying lifecycle semantics inside raw process adapters. The new CLI boundary accepts injected bootstrap hooks, token input, and screen repaint output. That keeps room bootstrap, websocket connection setup, and raw stdin/stdout effects outside this slice while still letting the runner be exercised through deterministic start, repaint, submit, and cleanup tests. Constraint: Room create/join UX and websocket transport bootstrapping stay outside ISSUE-21 Rejected: Wire process stdin/stdout directly into the runner | would make lifecycle cleanup and deterministic tests harder Confidence: high Scope-risk: narrow Directive: Keep raw terminal adapters and room bootstrap on top of session-terminal-cli unless the public lifecycle contract is revised first Tested: node --import tsx --test test/pvp-session-terminal-cli.test.ts Tested: npm run typecheck Tested: git diff --check Tested: npm test (1219/1219 pass) --- .../issues/ISSUE-21-session-terminal-cli.md | 70 +++++ docs/pvp/implementation/issues/README.md | 2 + docs/pvp/implementation/todo-breakdown.md | 1 + src/pvp/index.ts | 11 + src/pvp/session-terminal-cli.ts | 193 ++++++++++++ test/pvp-session-terminal-cli.test.ts | 296 ++++++++++++++++++ 6 files changed, 573 insertions(+) create mode 100644 docs/pvp/implementation/issues/ISSUE-21-session-terminal-cli.md create mode 100644 src/pvp/session-terminal-cli.ts create mode 100644 test/pvp-session-terminal-cli.test.ts 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/README.md b/docs/pvp/implementation/issues/README.md index ead026ef..d55cc074 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -35,6 +35,7 @@ 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) ## 왜 이 순서인가 @@ -72,6 +73,7 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | 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 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index 370e2c75..33e9d55e 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -162,6 +162,7 @@ - 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 생성은 아직 포함하지 않는다. --- diff --git a/src/pvp/index.ts b/src/pvp/index.ts index 3b242352..269fff9a 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -113,6 +113,17 @@ export { 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 { applyPvpServerEvent, createBattleCommandEnvelope, 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/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); + }); +}); From 025785c3e6c8a4251f32c1e4017e729cd0373a8d Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sun, 12 Apr 2026 01:04:02 +0900 Subject: [PATCH 27/30] Authorize room joins through server-issued PvP sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISSUE-22 범위로 방 생성/참가 HTTP 클라이언트와 세션 부트스트랩 계층을 추가했다. 클라이언트는 더 이상 전투 상태를 직접 구성하지 않고, 서버가 발급한 room projection과 auth token을 기반으로 세션 연결만 시작한다. 동시에 구현 이슈 문서를 보강해 다음 slice인 stdio adapter 작업과 계약 경계를 명시했다. Constraint: 초기 멀티플레이어는 서버 권위 전투 로직을 유지해야 한다 Constraint: 브라우저 DOM 타입 없이 Node/CLI 환경에서 동작해야 한다 Rejected: 방 응답을 any로 통과시키기 | 클라이언트-서버 계약 검증이 약해짐 Rejected: 부트스트랩 없이 각 호출 지점에서 세션 생성 | room/auth 처리 중복 증가 Confidence: high Scope-risk: moderate Reversibility: clean Directive: 이후 stdin/stdout adapter는 이 부트스트랩 계층 위에서만 세션 연결을 시작하고 room view를 임의 조작하지 말 것 Tested: node --import tsx --test test/pvp-room-http-client.test.ts test/pvp-session-bootstrap.test.ts Tested: npm run typecheck Tested: npm test Tested: git diff --check Not-tested: 실제 서버와의 end-to-end room create/join 네트워크 연동 Related: ISSUE-22 Related: ISSUE-23 --- .../issues/ISSUE-22-room-session-bootstrap.md | 67 ++++ ...SSUE-23-session-terminal-stdio-adapters.md | 46 +++ docs/pvp/implementation/issues/README.md | 4 + docs/pvp/implementation/todo-breakdown.md | 2 + src/pvp/index.ts | 28 ++ src/pvp/room-http-client.ts | 301 ++++++++++++++++++ src/pvp/session-bootstrap.ts | 166 ++++++++++ test/pvp-room-http-client.test.ts | 233 ++++++++++++++ test/pvp-session-bootstrap.test.ts | 195 ++++++++++++ 9 files changed, 1042 insertions(+) create mode 100644 docs/pvp/implementation/issues/ISSUE-22-room-session-bootstrap.md create mode 100644 docs/pvp/implementation/issues/ISSUE-23-session-terminal-stdio-adapters.md create mode 100644 src/pvp/room-http-client.ts create mode 100644 src/pvp/session-bootstrap.ts create mode 100644 test/pvp-room-http-client.test.ts create mode 100644 test/pvp-session-bootstrap.test.ts 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 index d55cc074..0fedf018 100644 --- a/docs/pvp/implementation/issues/README.md +++ b/docs/pvp/implementation/issues/README.md @@ -36,6 +36,8 @@ 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) ## 왜 이 순서인가 @@ -74,6 +76,8 @@ ruleset, restricted 목록, 레벨 압축, 치트 오염 판정이 먼저 고정 | 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 | ## 공통 실행 규칙 diff --git a/docs/pvp/implementation/todo-breakdown.md b/docs/pvp/implementation/todo-breakdown.md index 33e9d55e..294b50df 100644 --- a/docs/pvp/implementation/todo-breakdown.md +++ b/docs/pvp/implementation/todo-breakdown.md @@ -163,6 +163,8 @@ - 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를 얹는 슬라이스로 진행한다. --- diff --git a/src/pvp/index.ts b/src/pvp/index.ts index 269fff9a..0b5440ee 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -46,6 +46,34 @@ export { 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, 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/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-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; + }, + ); + }); +}); From cf36009e405492b1d56b688dad7aeb8b2169672b Mon Sep 17 00:00:00 2001 From: Sangwon Lee Date: Sun, 12 Apr 2026 01:28:24 +0900 Subject: [PATCH 28/30] Enable trusted terminal control for live PvP sessions Add a stdio adapter that owns tty/readline input handling and a live PvP CLI entrypoint that boots a room session into the existing terminal runner. The library wrapper keeps battle resolution server-authoritative while the client only submits validated action tokens. Constraint: Initial live PvP UX must reuse the existing terminal runner instead of introducing a second UI stack Constraint: CLI smoke path must work via `node --import tsx src/cli/pvp-live.ts --help` Rejected: Hide the bootstrap behind a second bespoke UI layer | would duplicate session rendering and input semantics Rejected: Merge executable CLI parsing into the library wrapper | blurs testable boundaries between reusable runtime and process entrypoint Confidence: high Scope-risk: moderate Directive: Keep terminal abort handling separate from transport abort reasons; extend the stdio adapter instead of bypassing it Tested: Focused stdio/live CLI tests, CLI help smoke test, typecheck, full npm test Not-tested: Real websocket handshake against a live PvP server --- src/cli/pvp-live.ts | 247 ++++++++++++++++ src/pvp/index.ts | 19 ++ src/pvp/live-session-cli.ts | 123 ++++++++ src/pvp/session-terminal-stdio.ts | 364 ++++++++++++++++++++++++ test/pvp-live-cli.test.ts | 222 +++++++++++++++ test/pvp-session-terminal-stdio.test.ts | 187 ++++++++++++ 6 files changed, 1162 insertions(+) create mode 100644 src/cli/pvp-live.ts create mode 100644 src/pvp/live-session-cli.ts create mode 100644 src/pvp/session-terminal-stdio.ts create mode 100644 test/pvp-live-cli.test.ts create mode 100644 test/pvp-session-terminal-stdio.test.ts 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/index.ts b/src/pvp/index.ts index 0b5440ee..353b5390 100644 --- a/src/pvp/index.ts +++ b/src/pvp/index.ts @@ -152,6 +152,25 @@ export { 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, 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/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/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-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(); + }); +}); From 25543ac491342fbd05782fb2d45eb8bd70d55aff Mon Sep 17 00:00:00 2001 From: Mina Kim <70475010+eulneul@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:04:37 +0900 Subject: [PATCH 29/30] fix: address code review feedback on PvP live session stack (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendCurrentSnapshot: clone session before mutation so seq/updatedAt are not modified in-place before the transport send; consistent with the immutable pattern used everywhere else in PvpWsServer - battle-command-service: document why seenClientCommandIds linear scan is acceptable (bounded by turns × seats, cleared on battle end) - pvp-live CLI: fall back to PVP_AUTH_TOKEN / PVP_SERVER_URL env vars so tokens are not required in shell args (and exposed in history) Co-authored-by: Claude Sonnet 4.6 --- src/cli/pvp-live.ts | 15 +++++++++++---- src/server/battle/battle-command-service.ts | 2 ++ src/server/ws/pvp-ws-server.ts | 9 +++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/cli/pvp-live.ts b/src/cli/pvp-live.ts index cbb1bb04..105fe6ee 100644 --- a/src/cli/pvp-live.ts +++ b/src/cli/pvp-live.ts @@ -65,8 +65,8 @@ Commands: 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. + --server-url Base HTTP(S) URL for the PvP server (or set PVP_SERVER_URL). + --auth-token Auth token issued by the server (or set PVP_AUTH_TOKEN to avoid shell history exposure). Shared flags: --generation Battle generation key (for example: gen1, gen2, gen3, gen4). @@ -116,10 +116,17 @@ function parseArgs(argv: string[]): ParsedArgs { return { command, flags }; } +const FLAG_ENV_FALLBACKS: Record = { + 'auth-token': 'PVP_AUTH_TOKEN', + 'server-url': 'PVP_SERVER_URL', +}; + function requireFlag(flags: Record, name: string): string { - const value = flags[name]?.trim(); + const envKey = FLAG_ENV_FALLBACKS[name]; + const value = (flags[name] ?? (envKey ? process.env[envKey] : undefined))?.trim(); if (!value) { - throw new Error(`--${name} is required`); + const envHint = envKey ? ` (or set ${envKey})` : ''; + throw new Error(`--${name} is required${envHint}`); } return value; diff --git a/src/server/battle/battle-command-service.ts b/src/server/battle/battle-command-service.ts index f8e32e9a..227f2ee1 100644 --- a/src/server/battle/battle-command-service.ts +++ b/src/server/battle/battle-command-service.ts @@ -88,6 +88,8 @@ export function validateBattleCommand(args: { ); } + // seenClientCommandIds is bounded by (turns × seats + timeout auto-commands) and + // is cleared when the battle ends, so the linear scan is acceptable here. if (session.seenClientCommandIds.includes(clientCommandId)) { return reject( BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_DUPLICATE, diff --git a/src/server/ws/pvp-ws-server.ts b/src/server/ws/pvp-ws-server.ts index a39147ca..181969f3 100644 --- a/src/server/ws/pvp-ws-server.ts +++ b/src/server/ws/pvp-ws-server.ts @@ -416,15 +416,16 @@ export class PvpWsServer { sentAt, payload: buildRoomSnapshotPayload(session, seat, new Date(sentAt)), }; - session.nextSeq += 1; - session.updatedAt = sentAt; - session.eventLog.push({ + const nextSession = cloneSession(session); + nextSession.nextSeq += 1; + nextSession.updatedAt = sentAt; + nextSession.eventLog.push({ seat, type: 'room.snapshot', seq: snapshot.seq, sentAt, }); - this.sessionsByRoomId.set(session.roomId, cloneSession(session)); + this.sessionsByRoomId.set(nextSession.roomId, nextSession); transport.send(snapshot); } From 9ce9b2151474f4115c7ba740c6db8225854346ee Mon Sep 17 00:00:00 2001 From: ThunderConch Date: Sun, 12 Apr 2026 17:04:41 +0900 Subject: [PATCH 30/30] Revert "fix: address code review feedback on PvP live session stack (#37)" This reverts commit 25543ac491342fbd05782fb2d45eb8bd70d55aff. --- src/cli/pvp-live.ts | 15 ++++----------- src/server/battle/battle-command-service.ts | 2 -- src/server/ws/pvp-ws-server.ts | 9 ++++----- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/cli/pvp-live.ts b/src/cli/pvp-live.ts index 105fe6ee..cbb1bb04 100644 --- a/src/cli/pvp-live.ts +++ b/src/cli/pvp-live.ts @@ -65,8 +65,8 @@ Commands: resume Resume an existing room session. Required flags: - --server-url Base HTTP(S) URL for the PvP server (or set PVP_SERVER_URL). - --auth-token Auth token issued by the server (or set PVP_AUTH_TOKEN to avoid shell history exposure). + --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). @@ -116,17 +116,10 @@ function parseArgs(argv: string[]): ParsedArgs { return { command, flags }; } -const FLAG_ENV_FALLBACKS: Record = { - 'auth-token': 'PVP_AUTH_TOKEN', - 'server-url': 'PVP_SERVER_URL', -}; - function requireFlag(flags: Record, name: string): string { - const envKey = FLAG_ENV_FALLBACKS[name]; - const value = (flags[name] ?? (envKey ? process.env[envKey] : undefined))?.trim(); + const value = flags[name]?.trim(); if (!value) { - const envHint = envKey ? ` (or set ${envKey})` : ''; - throw new Error(`--${name} is required${envHint}`); + throw new Error(`--${name} is required`); } return value; diff --git a/src/server/battle/battle-command-service.ts b/src/server/battle/battle-command-service.ts index 227f2ee1..f8e32e9a 100644 --- a/src/server/battle/battle-command-service.ts +++ b/src/server/battle/battle-command-service.ts @@ -88,8 +88,6 @@ export function validateBattleCommand(args: { ); } - // seenClientCommandIds is bounded by (turns × seats + timeout auto-commands) and - // is cleared when the battle ends, so the linear scan is acceptable here. if (session.seenClientCommandIds.includes(clientCommandId)) { return reject( BATTLE_COMMAND_REJECTION_CODES.PVP_COMMAND_DUPLICATE, diff --git a/src/server/ws/pvp-ws-server.ts b/src/server/ws/pvp-ws-server.ts index 181969f3..a39147ca 100644 --- a/src/server/ws/pvp-ws-server.ts +++ b/src/server/ws/pvp-ws-server.ts @@ -416,16 +416,15 @@ export class PvpWsServer { sentAt, payload: buildRoomSnapshotPayload(session, seat, new Date(sentAt)), }; - const nextSession = cloneSession(session); - nextSession.nextSeq += 1; - nextSession.updatedAt = sentAt; - nextSession.eventLog.push({ + session.nextSeq += 1; + session.updatedAt = sentAt; + session.eventLog.push({ seat, type: 'room.snapshot', seq: snapshot.seq, sentAt, }); - this.sessionsByRoomId.set(nextSession.roomId, nextSession); + this.sessionsByRoomId.set(session.roomId, cloneSession(session)); transport.send(snapshot); }