From 314c98fe66d0d40b6f14b78502cb6d02593da1f3 Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 22 Jun 2026 10:48:42 +0900 Subject: [PATCH] feat: expand experimental online multiplayer --- docs/development.md | 32 +- docs/history.md | 55 +++ docs/map.md | 18 +- docs/spec.md | 43 +- docs/todo.md | 20 +- docs/update.md | 39 ++ package-lock.json | 260 ++++++++++- package.json | 4 +- server/index.js | 547 +++++++++++++++++++++++- server/integration-test.js | 196 ++++++++- src/components/cards/GameCard.vue | 47 +- src/components/game-board/GameBoard.vue | 524 ++++++++++++++++++++--- src/stores/room.js | 323 +++++++++++++- src/views/GameView.vue | 43 +- src/views/LobbyView.vue | 29 ++ 15 files changed, 2071 insertions(+), 109 deletions(-) diff --git a/docs/development.md b/docs/development.md index e59e8a7..b6d5504 100644 --- a/docs/development.md +++ b/docs/development.md @@ -32,7 +32,16 @@ npm install ## 개발 서버 실행 -실시간 대기실을 사용하므로 터미널 두 개를 연다. +가장 간단한 방법은 프로젝트 폴더에서 다음 명령 하나를 실행하는 것이다. + +```bash +npm run dev:all +``` + +이 명령은 Vue 개발 서버와 Socket.IO 서버를 동시에 실행한다. 화면에 표시되는 +Vite의 `Local` 주소를 브라우저에서 연다. + +각 서버 로그를 별도 터미널에서 보고 싶다면 터미널 두 개를 연다. 첫 번째 터미널에서 Socket.IO 서버를 실행한다. @@ -57,6 +66,12 @@ Local: http://localhost:5173/ Vite는 브라우저의 `/socket.io` 요청을 로컬 `31873` 포트로 전달한다. 두 개발 서버를 종료하려면 각각의 터미널에서 `Control + C`를 누른다. +`npm run dev:all`을 사용했다면 해당 터미널에서 `Control + C`를 한 번 누르면 +두 서버가 함께 종료된다. + +- Vue 파일 변경은 자동으로 화면에 반영된다. +- `server/` 파일을 변경한 경우에는 `Control + C`로 종료한 뒤 + `npm run dev:all`을 다시 실행한다. ## 같은 네트워크의 다른 기기에서 테스트 @@ -105,7 +120,7 @@ VITE_CARD_ASSET_BASE_URL=http://playuniare.sori.studio/card-assets ```bash git pull npm install -npm run dev +npm run dev:all ``` 작업이 끝난 뒤: @@ -142,11 +157,24 @@ SOCKET_SERVER_PORT=31874 npm run dev ### 실시간 서버에 연결되지 않는 경우 - `npm run dev:server` 터미널이 실행 중인지 확인한다. +- 명령 하나로 실행하려면 기존 개발 서버를 모두 종료한 뒤 + `npm run dev:all`을 사용한다. - 서버 터미널에 `listening on port 31873` 메시지가 있는지 확인한다. - 포트를 바꿨다면 Vue와 Socket.IO 서버를 같은 `SOCKET_SERVER_PORT`로 실행했는지 확인한다. - 브라우저 개발자 도구 Network 탭에서 `/socket.io` 요청을 확인한다. +### `EMFILE: too many open files`가 나오는 경우 + +이 프로젝트의 기본 `dev:server`는 파일 감시를 사용하지 않으므로 최신 +`package.json`에서는 발생하지 않아야 한다. 기존 터미널을 모두 종료하고 +다음 명령을 다시 실행한다. + +```bash +npm install +npm run dev:all +``` + ### 패키지 오류가 발생하는 경우 먼저 다음을 다시 실행한다. diff --git a/docs/history.md b/docs/history.md index 6f867b4..b716474 100644 --- a/docs/history.md +++ b/docs/history.md @@ -85,3 +85,58 @@ - Socket.IO 포트는 NAS 호스트에 직접 공개하지 않는다. - 현재 서버 재시작 시 방이 사라지는 단순한 구조를 유지하고, 게임 상태 동기화와 재접속 복구는 후속 단계에서 구현한다. + +## 2026-06-19: 온라인 선후공·턴·공개 카드 동기화 + +- 선후공은 클라이언트 선택이 아니라 서버가 무작위로 한 명만 지정한다. +- 양쪽 초기 손패 확정 후 선공부터 현재 차례를 시작한다. +- 현재 차례인 플레이어만 서버에 턴 종료를 요청할 수 있다. +- 플레이어는 자신의 공개 카드 상태만 제출하며 서버는 상대 상태를 직접 + 수정하는 요청을 제공하지 않는다. +- 손패와 덱은 장수만 공유하고 카드 식별 정보는 서버 공개 상태에서 제외한다. +- 구현 복잡도를 낮추기 위해 현재는 카드별 이벤트 대신 플레이어별 공개 상태 + 스냅샷을 전송한다. 재접속과 순서 보장은 후속 단계에서 보강한다. + +## 2026-06-19: 손패 트레이와 단기 재접속 + +- 상하 3행 플레이매트 구조를 유지하고 좌우 회전 배치는 채택하지 않았다. +- 하단 Action Point 영역을 손패가 덮지 않도록 플레이매트 밖에 전용 손패 + 트레이를 추가했다. +- 손패 카드는 축소해 한 줄로 표시하고 넘치는 카드는 가로 스크롤로 확인한다. +- Socket.IO 소켓 ID와 별개인 플레이어 식별자와 복구 토큰을 도입했다. +- 연결 종료 즉시 방에서 제거하지 않고 60초 동안 같은 좌석의 재접속을 + 허용한다. +- 비공개 덱·손패와 카드 위치는 플레이어별 브라우저 저장소에 보관해 + 새로고침 후 복구한다. +- 서버는 상대에게 공개할 카드만 보관하며 개인 손패 원본은 받지 않는 구조를 + 유지한다. + +## 2026-06-19: 로컬 통합 개발 명령 + +- Vue만 실행했을 때 발생하는 Socket.IO 연결 오류를 줄이기 위해 + `concurrently`를 개발 의존성으로 추가했다. +- `npm run dev:all` 명령 하나로 Vite와 Socket.IO 개발 서버를 함께 실행한다. + +## 2026-06-19: 방 종료와 진행 중 게임 복귀 + +- 방 상태가 꼬여도 사용자가 직접 초기화할 수 있도록 대기실과 게임 화면에 + `방 나가기`를 추가했다. +- 한 플레이어가 명시적으로 방을 나가면 방 전체를 종료하고 상대에게 종료 + 사유를 전달한다. +- 대기실에서 기존 방을 종료하고 즉시 새 방을 만드는 동선을 추가했다. +- 진행 중 게임 세션으로 재접속하면 서버가 덱 쿼리와 좌석을 다시 전달해 + 게임 화면으로 자동 복귀시킨다. +- 명시적 종료와 서버의 방 종료 알림에서는 브라우저 재접속 정보와 개인 게임 + 스냅샷을 함께 삭제한다. + +## 2026-06-22: 서버 권위 비공개 덱 순서 + +- 서버는 카드 내용 대신 플레이어별 카드 인스턴스 식별자 순서만 보관한다. +- 온라인 셔플은 Node.js 암호학적 난수를 사용하고 결과 순서를 해당 + 플레이어에게만 반환한다. +- 드로우, 여러 장 공개와 라이프 배치는 서버 비공개 덱의 맨 위 식별자를 + 제거한 결과로 처리한다. +- 덱 위·아래 이동은 서버 순서에도 반영해 화면 장수와 실제 드로우 결과가 + 어긋나지 않도록 했다. +- 공개 카드 상태에는 단조 증가 리비전을 추가해 지연된 이전 상태가 최신 + 상태를 덮지 못하게 했다. diff --git a/docs/map.md b/docs/map.md index 926004e..7e308e3 100644 --- a/docs/map.md +++ b/docs/map.md @@ -15,6 +15,9 @@ - 서버 기반 방 생성·방 코드 참가 - 양쪽 플레이어의 좌석·덱 등록·준비 상태 - 양쪽 준비 완료 시 게임 화면 자동 이동 + - 방 나가기 + - 현재 방 종료 후 새 방 만들기 + - 진행 중 게임 세션 발견 시 게임 화면 자동 복귀 - 유효한 덱으로 로컬 게임 테스트 ### `/game` @@ -33,14 +36,23 @@ - 불러온 덱을 내 덱 영역에 뒷면으로 쌓기 - 잘못된 덱 데이터 오류 안내 - 내 덱 1장 드로우와 셔플 + - 온라인 서버 권위 셔플·드로우·여러 장 공개 - 덱 위 1~10장 공개 - 중앙 공개 영역 자동 정렬 - - 하단 손패 자동 정렬 + - 플레이매트와 분리된 화면 하단 한 줄 손패 트레이 + - 축소 손패 카드와 가로 스크롤 - 우클릭 메뉴의 손패·덱 위·덱 아래·무덤 이동 - 첫 손패 7장 - - 선후공 선택과 1회 멀리건 + - 로컬 선후공 선택과 1회 멀리건 + - 온라인 서버 무작위 선후공 지정 - 라이프 7장 세팅 - 턴 시작 AP·드로우·일괄 언탭 + - 현재 차례 강조와 서버 기반 턴 종료·교대 + - 상대 공개 카드·덱·손패·라이프 장수 실시간 표시 + - 상대 카드 반대편 좌표 표시와 조작 차단 + - 새로고침·단기 연결 끊김의 같은 좌석 재접속 + - 게임 화면의 방 나가기 + - 상대가 방을 나가면 대기실 자동 복귀 - 카드별 자동 언탭 제외 잠금 - 마우스·터치 포인터 기반 자유 드래그 - 카드 더블클릭 탭·언탭 @@ -50,7 +62,7 @@ - 선택 카드 좌표·레이어·앞뒤·탭 상태 표시 - 좌우 플레이어 정보 패널 - 라이프, 덱·손패 장수, 연결 상태와 메모 자리 -- 다음 변경: 게임 화면의 카드 상태를 Socket.IO 서버와 동기화 +- 다음 변경: 카드 조작 이벤트별 세부 검증과 진행 중 표시 - 문서 제목과 기본 언어: `index.html`의 `Play Uniare`, 한국어 ## 예정 화면 diff --git a/docs/spec.md b/docs/spec.md index 16b1280..b9f0ce2 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -84,7 +84,26 @@ - 방 상태와 덱 등록 결과는 Socket.IO 서버가 최종 검증한다. - 두 플레이어가 모두 준비하면 서버가 각 플레이어에게 자신의 덱 쿼리와 좌석을 전달하고 `/game`으로 자동 이동시킨다. +- 서버가 게임 시작 시 두 플레이어 중 한 명을 무작위 선공으로 지정한다. +- 온라인 게임에서는 사용자가 선공·후공을 직접 선택할 수 없다. +- 양쪽 플레이어가 첫 손패와 라이프를 확정한 뒤 선공 플레이어의 턴부터 + 시작한다. +- 현재 차례의 플레이어만 턴 종료를 요청할 수 있으며 서버가 상대에게 차례를 + 넘긴다. - 방은 서버 메모리에 저장하므로 서버 재시작 시 종료된다. +- 브라우저 새로고침이나 짧은 네트워크 끊김에는 재접속 식별자로 같은 좌석을 + 복구한다. +- 내 덱 순서, 손패, 카드 위치와 준비 상태는 플레이어별 브라우저 저장소에 + 보관해 새로고침 후 복구한다. +- 개인 저장 상태는 상대 플레이어에게 전송하지 않는다. +- 연결이 끊긴 플레이어의 자리는 60초 동안 보존하고, 그 안에 복구되지 않으면 + 진행 중인 게임을 종료한다. +- 진행 중인 게임이 있는 사용자가 대기실이나 사이트 첫 화면으로 돌아오면 + 서버가 자신의 덱 쿼리와 좌석을 다시 전달해 게임 화면으로 복귀시킨다. +- 명시적인 `방 나가기`는 방 전체를 종료하고 양쪽 플레이어의 재접속 정보와 + 개인 게임 스냅샷을 삭제한다. +- 대기실에서 `현재 방 종료 후 새 방 만들기`로 먹통이 된 방을 정리하고 즉시 + 새 초대 코드를 만들 수 있다. - 기능 개발을 위해 유효한 덱으로 `/game`에 진입하는 로컬 게임 테스트를 제공한다. @@ -94,6 +113,9 @@ - 상대 효과로 내 카드가 제거될 때에도 내가 직접 내 카드를 이동한다. - 서버는 모든 카드 조작에서 요청 사용자와 카드 소유자를 비교한다. - 상대 손패에는 장수만 공개하고 카드 식별 정보는 공개하지 않는다. +- 각 플레이어는 자신의 공개 카드 상태만 서버에 제출할 수 있다. +- 서버는 필드, 공개, 무덤, 라이프 카드만 상대에게 전달한다. +- 덱과 손패는 카드 목록을 보내지 않고 장수만 전달한다. ## 화면 좌표 @@ -113,6 +135,9 @@ - 로컬 모드: 브라우저 메모리, 추후 복구가 필요하면 localStorage 검토 - 온라인 모드: 서버가 방 상태의 최종 권위를 가진다. +- 온라인 개인 비공개 상태: 현재 단계에서는 플레이어 브라우저 저장소에 + 암호화 없이 저장한다. 공용 PC에서는 게임 후 브라우저 데이터를 정리해야 + 한다. - 초기 서버: Node.js와 Socket.IO, 메모리 기반 방 상태 - 운영 안정화 단계: Redis 또는 데이터베이스 저장 검토 - 서버의 공개 방 상태를 Pinia가 수신해 화면에 표시한다. @@ -133,13 +158,25 @@ - 키보드 포커스로도 카드를 선택할 수 있어야 한다. - 개발 기준 화면에서는 같은 카드의 독립 인스턴스 3장으로 겹침과 선택 동작을 검증한다. +- 온라인 게임에서 내 공개 카드는 상대 화면의 반대편 좌표로 변환해 표시한다. +- 상대 카드는 선택, 드래그, 더블클릭, 우클릭 조작을 할 수 없다. +- 공개 상태 전송에는 증가하는 리비전 번호를 붙이고, 서버와 상대 브라우저는 + 이전 리비전이 늦게 도착하면 무시한다. ## 덱·손패·무덤 동작 - 덱 카드는 보드의 내 덱 위치에 뒷면으로 겹쳐 표시한다. +- 온라인 게임에서 서버는 카드 내용이 아닌 인스턴스 식별자 순서만 비공개로 + 관리한다. +- 온라인 셔플은 서버의 암호학적 난수로 순서를 결정한다. +- 온라인 드로우, 덱 위 여러 장 공개, 라이프 배치는 서버가 반환한 맨 위 카드 + 식별자를 사용한다. +- 덱 위·아래 이동은 서버의 비공개 덱 순서에도 같은 위치로 반영한다. - `1장 드로우`는 가장 높은 `stackOrder`의 덱 카드를 손패로 이동한다. - 손패 카드는 앞면으로 표시하고 보드 하단 중앙에 자동 정렬한다. -- 손패 카드가 많아지면 제한된 폭 안에서 카드 간격을 줄인다. +- 손패는 플레이매트 위가 아니라 화면 하단의 별도 한 줄 트레이에 표시한다. +- 손패 카드는 필드 카드보다 작게 표시하고 장수가 많아지면 가로로 스크롤한다. +- 플레이매트의 `ACTION POINT AREA`는 손패 카드로 가리지 않는다. - 손패·덱·무덤 카드를 드래그하면 필드 카드로 전환한다. - 셔플은 덱 카드의 레이어 순서를 무작위로 다시 배정하고 모두 뒷면·언탭으로 정리한다. @@ -170,11 +207,15 @@ - 턴 시작 시 내 필드의 탭 카드를 모두 언탭한다. - `isUntapLocked`가 설정된 카드는 턴 시작 자동 언탭에서 제외한다. - 언탭 제외 잠금은 카드 우클릭 메뉴에서 설정·해제한다. +- 온라인 게임에서는 서버의 현재 차례가 내 플레이어로 바뀔 때 턴 시작 + 언탭, AP 갱신과 드로우를 한 번 적용한다. ## 게임 화면 - 실제 게임 화면에는 고정 헤더를 표시하지 않는다. - 16:9 플레이 보드를 뷰포트 안에서 가능한 가장 크게 표시한다. +- 하단 손패 트레이 높이를 제외한 영역에서 16:9 플레이 보드를 가능한 가장 + 크게 표시한다. - 남는 공간은 검은색 레터박스로 처리한다. - `F` 키 또는 우측 상단의 반투명 버튼으로 브라우저 전체화면을 전환한다. - `Esc` 키로 브라우저 전체화면과 카드 우클릭 메뉴를 닫을 수 있다. diff --git a/docs/todo.md b/docs/todo.md index c354ea6..de1bae3 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -47,10 +47,20 @@ - [x] 방 만들기·참가 대기실 UI - [x] 방 만들기·참가 서버 동기화 - [x] 플레이어 좌석 할당 -- [ ] 게임 카드 플레이어 소유권 -- [ ] 카드 조작 권한 서버 검증 -- [ ] 상대 손패 비공개 전송 -- [ ] 상태 스냅샷과 재접속 +- [x] 게임 카드 플레이어 소유권 기반 상태 제출 +- [x] 상대 카드 직접 조작 차단 +- [x] 상대 손패·덱 카드 식별 정보 비공개 +- [x] 공개 카드 이동·탭·뒤집기·영역 상태 동기화 +- [x] 서버 무작위 선후공과 현재 차례 동기화 +- [ ] 카드 조작 이벤트별 세부 서버 검증 +- [x] 덱 인스턴스 순서와 셔플의 서버 권위 처리 +- [x] 서버 권위 드로우·여러 장 공개·라이프 배치 +- [x] 60초 유예 기반 같은 좌석 재접속 +- [x] 브라우저 저장소 기반 내 비공개 덱·손패 상태 재접속 복구 +- [x] 진행 중 게임의 대기실·첫 화면 접근 시 자동 복귀 +- [x] 방 나가기와 새 방 만들기 복구 동선 +- [ ] 전체 게임 상태 스냅샷 복구 +- [x] 명시적인 방 종료 시 플레이어별 로컬 스냅샷 자동 삭제 - [x] 연결 종료 시 방 참가자 정리 - [x] NAS WebSocket 프록시 배포 - [ ] 비어 있지 않은 방의 유휴 만료 처리 @@ -64,6 +74,8 @@ - [ ] 이미지 로딩 실패 폴백 - [ ] 접근성 키보드 조작 범위 검토 - [ ] 서버 재시작 시 방 종료 안내 +- [x] 공개 카드 상태 리비전으로 오래된 제출 무시 +- [ ] 비공개 덱 조작 중 중복 클릭 잠금 - [ ] Redis 또는 상태 저장 필요성 검토 ## 확인이 필요한 정책 diff --git a/docs/update.md b/docs/update.md index 656d100..74d02d6 100644 --- a/docs/update.md +++ b/docs/update.md @@ -64,3 +64,42 @@ - 방을 이동할 때 이전 Socket.IO 채널에서 확실히 나가도록 정리했다. - Nginx의 `/socket.io` 프록시와 Docker 내부 실시간 서버 서비스를 추가했다. - 방 생성부터 양쪽 게임 시작까지 검증하는 2인 통합 테스트를 추가했다. +- 온라인 선후공을 서버 무작위 지정으로 변경하고 양쪽의 중복 선공 선택을 + 제거했다. +- 양쪽 손패 확정 후 선공부터 시작하는 현재 차례와 턴 종료 교대를 추가했다. +- 필드·공개·무덤·라이프 카드 상태와 비공개 영역 장수를 상대 화면에 + 동기화했다. +- 상대 카드는 반대편 좌표로 표시하고 직접 조작할 수 없도록 했다. +- 손패와 덱 카드 목록이 상대에게 전달되지 않도록 서버 공개 상태를 제한했다. +- 통합 테스트 범위를 선후공, 초기 준비, 공개 카드 비공개 필터, 턴 교대까지 + 확장했다. +- 플레이매트의 Action Point 영역을 침범하던 손패를 화면 하단 전용 트레이로 + 분리했다. +- 손패 카드를 축소된 한 줄 목록으로 표시하고 가로 스크롤을 지원한다. +- 새로고침과 짧은 네트워크 끊김 후 같은 방·좌석·턴을 복구하는 재접속 + 식별자를 추가했다. +- 연결이 끊긴 플레이어의 자리를 60초간 보존하고 재접속 통합 테스트를 + 추가했다. +- 내 덱 순서, 손패, 카드 위치, 멀리건과 준비 상태를 플레이어별 브라우저 + 저장소에 보관해 새로고침 후 복구하도록 했다. +- 로컬에서 Vue만 실행해 웹소켓 오류가 발생하는 문제를 예방하도록 + `npm run dev:all` 통합 개발 명령을 추가했다. +- macOS 파일 감시 한도에서 Socket.IO 개발 서버가 `EMFILE`로 종료되는 + 문제를 피하도록 서버 개발 명령의 `--watch`를 제거했다. +- 대기실과 게임 화면에 `방 나가기`를 추가하고 한쪽이 나가면 양쪽 모두 + 대기실 상태로 복귀하도록 했다. +- 먹통이 된 방을 정리하고 즉시 새 초대 코드를 만드는 버튼을 추가했다. +- 진행 중인 게임에서 대기실로 이동하거나 브라우저를 다시 열어도 게임 + 화면으로 자동 복귀하도록 재접속 응답을 보강했다. +- 방 종료 시 재접속 세션과 플레이어별 게임 스냅샷을 삭제하도록 했다. + +## 2026-06-22 + +- 온라인 덱 셔플을 클라이언트 난수 대신 서버의 암호학적 난수로 변경했다. +- 서버가 플레이어별 비공개 덱 인스턴스 순서를 관리하도록 추가했다. +- 드로우, 여러 장 공개, 라이프 배치와 덱 위·아래 이동을 서버 순서에 + 연결했다. +- 공개 카드 상태에 리비전 번호를 추가해 늦게 도착한 이전 상태를 무시하도록 + 했다. +- 서버 통합 테스트에 비공개 덱 셔플·드로우·되돌리기와 오래된 리비전 거부를 + 추가했다. diff --git a/package-lock.json b/package-lock.json index 08b2ace..a243a0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.1", "@vitejs/plugin-vue": "^6.0.6", + "concurrently": "^10.0.3", "tailwindcss": "^4.3.1", "vite": "^8.0.12" } @@ -1006,6 +1007,32 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -1057,6 +1084,19 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -1072,6 +1112,46 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -1152,6 +1232,13 @@ "node": ">=8" } }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/engine.io": { "version": "6.6.9", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", @@ -1221,6 +1308,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -1264,6 +1361,29 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1877,12 +1997,35 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/socket.io": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", @@ -1957,6 +2100,40 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/superjson": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", @@ -1969,6 +2146,19 @@ "node": ">=16" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/tailwindcss": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", @@ -2006,12 +2196,22 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "devOptional": true, + "license": "0BSD" }, "node_modules/ufo": { "version": "1.6.4", @@ -2251,6 +2451,24 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -2280,6 +2498,16 @@ "node": ">=0.4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -2294,6 +2522,34 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } } } } diff --git a/package.json b/package.json index 16cf672..6d32227 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "dev:server": "node --watch server/index.js", + "dev:server": "node server/index.js", + "dev:all": "concurrently --kill-others --names SOCKET,VITE --prefix-colors magenta,cyan \"npm run dev:server\" \"npm run dev\"", "start:server": "node server/index.js", "test:server": "node server/integration-test.js", "build": "vite build", @@ -21,6 +22,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.1", "@vitejs/plugin-vue": "^6.0.6", + "concurrently": "^10.0.3", "tailwindcss": "^4.3.1", "vite": "^8.0.12" } diff --git a/server/index.js b/server/index.js index d386976..46c5c1f 100644 --- a/server/index.js +++ b/server/index.js @@ -1,5 +1,5 @@ import { createServer } from 'node:http' -import { randomInt } from 'node:crypto' +import { randomInt, randomUUID } from 'node:crypto' import { Server } from 'socket.io' import { validateDeckRegistrationUrl } from '../src/services/deckRegistration.js' @@ -7,9 +7,13 @@ const SERVER_PORT = Number(process.env.SOCKET_SERVER_PORT || 31873) const ROOM_CODE_CHARACTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const ROOM_CODE_LENGTH = 6 const MAXIMUM_ROOM_PLAYERS = 2 +const MAXIMUM_GAME_CARDS = 100 +const PUBLIC_CARD_ZONES = new Set(['field', 'graveyard', 'life', 'reveal']) +const PLAYER_RECONNECT_GRACE_PERIOD_MILLISECONDS = 60_000 const rooms = new Map() const socketRoomCodes = new Map() +const socketPlayerIds = new Map() /** * 사람이 읽기 쉬운 비공개 방 코드를 생성한다. @@ -42,7 +46,7 @@ function createPublicRoomState(room) { players: Array.from(room.players.values()).map((player) => ({ playerId: player.playerId, seat: player.seat, - isConnected: true, + isConnected: player.isConnected, isReady: player.isReady, hasRegisteredDeck: Boolean(player.deckRegistration?.isValid), deckSeriesName: player.deckRegistration?.seriesName || '', @@ -64,6 +68,142 @@ function broadcastRoomState(socketServer, room) { .emit('room:state', createPublicRoomState(room)) } +/** + * 현재 게임 턴과 준비 상태를 참가자에게 전송한다. + * + * @param {Server} socketServer Socket.IO 서버 + * @param {object} room 방 상태 + * @returns {void} + */ +function broadcastGameState(socketServer, room) { + if (!room.gameState) return + + socketServer.to(room.roomCode).emit('game:state', { + firstPlayerId: room.gameState.firstPlayerId, + currentTurnPlayerId: room.gameState.currentTurnPlayerId, + turnSequence: room.gameState.turnSequence, + setupConfirmedPlayerIds: Array.from( + room.gameState.setupConfirmedPlayerIds, + ), + }) +} + +/** + * 클라이언트가 제출한 카드 상태를 공개 가능한 값으로 제한한다. + * + * @param {unknown} requestedCards 클라이언트 카드 상태 + * @returns {object[]} 검증된 공개 카드 목록 + */ +function sanitizePublicCardState(requestedCards) { + if (!Array.isArray(requestedCards)) return [] + + return requestedCards + .slice(0, MAXIMUM_GAME_CARDS) + .filter( + (card) => + card && + typeof card.instanceId === 'string' && + typeof card.cardNumber === 'string' && + typeof card.imageUrl === 'string' && + PUBLIC_CARD_ZONES.has(card.zone), + ) + .map((card) => ({ + instanceId: card.instanceId.slice(0, 120), + cardNumber: card.cardNumber.slice(0, 120), + imageUrl: card.imageUrl.slice(0, 500), + zone: card.zone, + position: { + x: Math.min(Math.max(Number(card.position?.x) || 0.5, 0), 1), + y: Math.min(Math.max(Number(card.position?.y) || 0.5, 0), 1), + }, + isFaceUp: card.zone === 'life' ? false : Boolean(card.isFaceUp), + isTapped: Boolean(card.isTapped), + isUntapLocked: Boolean(card.isUntapLocked), + stackOrder: Number.isFinite(Number(card.stackOrder)) + ? Number(card.stackOrder) + : 0, + })) +} + +/** + * 카드 인스턴스 식별자 목록을 중복 없이 검증한다. + * + * @param {unknown} requestedInstanceIds 클라이언트가 제출한 식별자 목록 + * @returns {string[] | null} 검증된 목록 또는 실패 시 null + */ +function sanitizeDeckInstanceIds(requestedInstanceIds) { + if ( + !Array.isArray(requestedInstanceIds) || + requestedInstanceIds.length > MAXIMUM_GAME_CARDS + ) { + return null + } + + const instanceIds = requestedInstanceIds.filter( + (instanceId) => + typeof instanceId === 'string' && + instanceId.length > 0 && + instanceId.length <= 120, + ) + + if ( + instanceIds.length !== requestedInstanceIds.length || + new Set(instanceIds).size !== instanceIds.length + ) { + return null + } + + return instanceIds +} + +/** + * 암호학적 난수로 카드 인스턴스 순서를 섞는다. + * + * @param {string[]} instanceIds 섞을 카드 식별자 목록 + * @returns {string[]} 새로 섞인 목록 + */ +function shuffleInstanceIds(instanceIds) { + const shuffledInstanceIds = [...instanceIds] + + for ( + let instanceIndex = shuffledInstanceIds.length - 1; + instanceIndex > 0; + instanceIndex -= 1 + ) { + const randomInstanceIndex = randomInt(instanceIndex + 1) + const heldInstanceId = shuffledInstanceIds[instanceIndex] + shuffledInstanceIds[instanceIndex] = + shuffledInstanceIds[randomInstanceIndex] + shuffledInstanceIds[randomInstanceIndex] = heldInstanceId + } + + return shuffledInstanceIds +} + +/** + * 상대 플레이어에게 제출자의 공개 카드와 영역별 장수를 전달한다. + * + * @param {Server} socketServer Socket.IO 서버 + * @param {object} room 방 상태 + * @param {object} player 상태를 제출한 플레이어 + * @returns {void} + */ +function broadcastPlayerGameState(socketServer, room, player) { + socketServer.to(room.roomCode).except(player.socketId).emit( + 'game:opponent-state', + { + playerId: player.playerId, + revision: player.publicStateRevision || 0, + cards: player.publicCards || [], + counts: player.gameCounts || { + deck: 0, + hand: 0, + life: 0, + }, + }, + ) +} + /** * 소켓이 현재 참가 중인 방과 플레이어 상태를 찾는다. * @@ -73,7 +213,8 @@ function broadcastRoomState(socketServer, room) { function findSocketRoomContext(socketId) { const roomCode = socketRoomCodes.get(socketId) const room = roomCode ? rooms.get(roomCode) : null - const player = room?.players.get(socketId) + const playerId = socketPlayerIds.get(socketId) + const player = playerId ? room?.players.get(playerId) : null return room && player ? { room, player } : null } @@ -89,14 +230,20 @@ function leaveCurrentRoom(socketServer, socket) { const socketId = socket.id const roomCode = socketRoomCodes.get(socketId) const room = roomCode ? rooms.get(roomCode) : null + const playerId = socketPlayerIds.get(socketId) socketRoomCodes.delete(socketId) + socketPlayerIds.delete(socketId) if (roomCode) { socket.leave(roomCode) } - if (!room) return + if (!room || !playerId) return - room.players.delete(socketId) + const player = room.players.get(playerId) + if (player?.disconnectTimer) { + clearTimeout(player.disconnectTimer) + } + room.players.delete(playerId) if (room.players.size === 0) { rooms.delete(roomCode) @@ -104,9 +251,71 @@ function leaveCurrentRoom(socketServer, socket) { } room.hasStarted = false + room.gameState = null + Array.from(room.players.values()).forEach((remainingPlayer) => { + remainingPlayer.isReady = false + }) broadcastRoomState(socketServer, room) } +/** + * 명시적인 방 나가기 요청 시 방 전체를 종료하고 참가자를 초기화한다. + * + * @param {Server} socketServer Socket.IO 서버 + * @param {object} room 종료할 방 + * @param {string} message 참가자에게 표시할 종료 사유 + * @returns {void} + */ +function closeRoomForAllPlayers(socketServer, room, message) { + Array.from(room.players.values()).forEach((player) => { + if (player.disconnectTimer) { + clearTimeout(player.disconnectTimer) + } + + if (player.socketId) { + const playerSocket = socketServer.sockets.sockets.get(player.socketId) + socketRoomCodes.delete(player.socketId) + socketPlayerIds.delete(player.socketId) + playerSocket?.leave(room.roomCode) + playerSocket?.emit('room:closed', { message }) + } + }) + + rooms.delete(room.roomCode) +} + +/** + * 연결이 끊긴 플레이어를 잠시 방에 보존해 재접속을 허용한다. + * + * @param {Server} socketServer Socket.IO 서버 + * @param {import('socket.io').Socket} socket 연결이 끊긴 소켓 + * @returns {void} + */ +function preserveDisconnectedPlayer(socketServer, socket) { + const roomContext = findSocketRoomContext(socket.id) + if (!roomContext) return + + const { room, player } = roomContext + socketRoomCodes.delete(socket.id) + socketPlayerIds.delete(socket.id) + player.isConnected = false + player.socketId = null + broadcastRoomState(socketServer, room) + + player.disconnectTimer = setTimeout(() => { + room.players.delete(player.playerId) + + if (room.players.size === 0) { + rooms.delete(room.roomCode) + return + } + + room.hasStarted = false + room.gameState = null + broadcastRoomState(socketServer, room) + }, PLAYER_RECONNECT_GRACE_PERIOD_MILLISECONDS) +} + /** * 양쪽 플레이어가 준비되면 각자 자신의 덱 정보로 게임 시작을 알린다. * @@ -125,15 +334,30 @@ function startRoomGameWhenReady(socketServer, room) { if (!canStartGame || room.hasStarted) return room.hasStarted = true + const firstPlayer = players[randomInt(players.length)] + room.gameState = { + firstPlayerId: firstPlayer.playerId, + currentTurnPlayerId: null, + turnSequence: 0, + setupConfirmedPlayerIds: new Set(), + } broadcastRoomState(socketServer, room) players.forEach((player) => { - socketServer.to(player.playerId).emit('game:started', { + socketServer.to(player.socketId).emit('game:started', { roomCode: room.roomCode, seat: player.seat, gameQuery: player.deckRegistration.gameQuery, + gameState: { + firstPlayerId: room.gameState.firstPlayerId, + currentTurnPlayerId: null, + turnSequence: 0, + setupConfirmedPlayerIds: [], + }, }) }) + + broadcastGameState(socketServer, room) } const httpServer = createServer((request, response) => { @@ -156,26 +380,37 @@ socketServer.on('connection', (socket) => { leaveCurrentRoom(socketServer, socket) const roomCode = createUniqueRoomCode() + const playerId = randomUUID() + const resumeToken = randomUUID() const room = { roomCode, hasStarted: false, players: new Map(), } const player = { - playerId: socket.id, + playerId, + resumeToken, + socketId: socket.id, seat: 'player-one', + isConnected: true, isReady: false, deckRegistration: null, + publicCards: [], + gameCounts: null, + publicStateRevision: 0, + privateDeckOrder: [], } - room.players.set(socket.id, player) + room.players.set(playerId, player) rooms.set(roomCode, room) socketRoomCodes.set(socket.id, roomCode) + socketPlayerIds.set(socket.id, playerId) socket.join(roomCode) acknowledge?.({ ok: true, - playerId: socket.id, + playerId, + resumeToken, room: createPublicRoomState(room), }) broadcastRoomState(socketServer, room) @@ -205,25 +440,102 @@ socketServer.on('connection', (socket) => { leaveCurrentRoom(socketServer, socket) + const playerId = randomUUID() + const resumeToken = randomUUID() const player = { - playerId: socket.id, + playerId, + resumeToken, + socketId: socket.id, seat: 'player-two', + isConnected: true, isReady: false, deckRegistration: null, + publicCards: [], + gameCounts: null, + publicStateRevision: 0, + privateDeckOrder: [], } - room.players.set(socket.id, player) + room.players.set(playerId, player) socketRoomCodes.set(socket.id, roomCode) + socketPlayerIds.set(socket.id, playerId) socket.join(roomCode) acknowledge?.({ ok: true, - playerId: socket.id, + playerId, + resumeToken, room: createPublicRoomState(room), }) broadcastRoomState(socketServer, room) }) + socket.on('room:resume', (resumeRequest, acknowledge) => { + const roomCode = + typeof resumeRequest?.roomCode === 'string' + ? resumeRequest.roomCode.trim().toUpperCase() + : '' + const room = rooms.get(roomCode) + const player = room?.players.get(resumeRequest?.playerId) + + if ( + !room || + !player || + player.resumeToken !== resumeRequest?.resumeToken + ) { + acknowledge?.({ ok: false, error: '복구할 게임 세션이 없습니다.' }) + return + } + + if (player.disconnectTimer) { + clearTimeout(player.disconnectTimer) + player.disconnectTimer = null + } + + player.socketId = socket.id + player.isConnected = true + socketRoomCodes.set(socket.id, roomCode) + socketPlayerIds.set(socket.id, player.playerId) + socket.join(roomCode) + + acknowledge?.({ + ok: true, + playerId: player.playerId, + room: createPublicRoomState(room), + gameState: room.gameState + ? { + firstPlayerId: room.gameState.firstPlayerId, + currentTurnPlayerId: room.gameState.currentTurnPlayerId, + turnSequence: room.gameState.turnSequence, + setupConfirmedPlayerIds: Array.from( + room.gameState.setupConfirmedPlayerIds, + ), + } + : null, + gameStartPayload: + room.hasStarted && player.deckRegistration?.gameQuery + ? { + roomCode: room.roomCode, + seat: player.seat, + gameQuery: player.deckRegistration.gameQuery, + gameState: room.gameState + ? { + firstPlayerId: room.gameState.firstPlayerId, + currentTurnPlayerId: + room.gameState.currentTurnPlayerId, + turnSequence: room.gameState.turnSequence, + setupConfirmedPlayerIds: Array.from( + room.gameState.setupConfirmedPlayerIds, + ), + } + : null, + } + : null, + }) + broadcastRoomState(socketServer, room) + broadcastGameState(socketServer, room) + }) + socket.on('player:register-deck', (deckUrlText, acknowledge) => { const roomContext = findSocketRoomContext(socket.id) @@ -267,12 +579,217 @@ socketServer.on('connection', (socket) => { startRoomGameWhenReady(socketServer, roomContext.room) }) - socket.on('room:leave', () => { - leaveCurrentRoom(socketServer, socket) + socket.on('game:confirm-setup', (acknowledge) => { + const roomContext = findSocketRoomContext(socket.id) + const gameState = roomContext?.room.gameState + + if (!roomContext || !roomContext.room.hasStarted || !gameState) { + acknowledge?.({ ok: false, error: '시작된 게임을 찾지 못했습니다.' }) + return + } + + gameState.setupConfirmedPlayerIds.add(roomContext.player.playerId) + + if ( + gameState.setupConfirmedPlayerIds.size === MAXIMUM_ROOM_PLAYERS && + !gameState.currentTurnPlayerId + ) { + gameState.currentTurnPlayerId = gameState.firstPlayerId + gameState.turnSequence = 1 + } + + broadcastGameState(socketServer, roomContext.room) + acknowledge?.({ ok: true }) + }) + + socket.on('game:publish-player-state', (requestedState, acknowledge) => { + const roomContext = findSocketRoomContext(socket.id) + + if (!roomContext || !roomContext.room.hasStarted) { + acknowledge?.({ ok: false, error: '시작된 게임을 찾지 못했습니다.' }) + return + } + + const requestedRevision = Number(requestedState?.revision) || 0 + if (requestedRevision <= roomContext.player.publicStateRevision) { + acknowledge?.({ + ok: true, + ignored: true, + revision: roomContext.player.publicStateRevision, + }) + return + } + + roomContext.player.publicStateRevision = requestedRevision + roomContext.player.publicCards = sanitizePublicCardState( + requestedState?.cards, + ) + roomContext.player.gameCounts = { + deck: Math.max(0, Number(requestedState?.counts?.deck) || 0), + hand: Math.max(0, Number(requestedState?.counts?.hand) || 0), + life: Math.max(0, Number(requestedState?.counts?.life) || 0), + } + + acknowledge?.({ ok: true, revision: requestedRevision }) + broadcastPlayerGameState( + socketServer, + roomContext.room, + roomContext.player, + ) + }) + + socket.on('game:request-opponent-state', () => { + const roomContext = findSocketRoomContext(socket.id) + if (!roomContext || !roomContext.room.hasStarted) return + + Array.from(roomContext.room.players.values()) + .filter((player) => player.playerId !== roomContext.player.playerId) + .forEach((player) => { + socket.emit('game:opponent-state', { + playerId: player.playerId, + revision: player.publicStateRevision || 0, + cards: player.publicCards || [], + counts: player.gameCounts || { + deck: 0, + hand: 0, + life: 0, + }, + }) + }) + }) + + socket.on('game:shuffle-private-deck', (requestedInstanceIds, acknowledge) => { + const roomContext = findSocketRoomContext(socket.id) + const instanceIds = sanitizeDeckInstanceIds(requestedInstanceIds) + + if (!roomContext || !roomContext.room.hasStarted || !instanceIds) { + acknowledge?.({ ok: false, error: '덱 순서를 갱신하지 못했습니다.' }) + return + } + + roomContext.player.privateDeckOrder = shuffleInstanceIds(instanceIds) + acknowledge?.({ + ok: true, + instanceIds: roomContext.player.privateDeckOrder, + }) + }) + + socket.on('game:draw-private-deck', (requestedCount, acknowledge) => { + const roomContext = findSocketRoomContext(socket.id) + const drawCount = Math.min( + Math.max(Number(requestedCount) || 1, 1), + 10, + ) + + if (!roomContext || !roomContext.room.hasStarted) { + acknowledge?.({ ok: false, error: '드로우할 게임을 찾지 못했습니다.' }) + return + } + + const drawnInstanceIds = [] + while ( + drawnInstanceIds.length < drawCount && + roomContext.player.privateDeckOrder.length > 0 + ) { + drawnInstanceIds.push(roomContext.player.privateDeckOrder.pop()) + } + + acknowledge?.({ ok: true, instanceIds: drawnInstanceIds }) + }) + + socket.on('game:place-private-deck-card', (placementRequest, acknowledge) => { + const roomContext = findSocketRoomContext(socket.id) + const instanceId = + typeof placementRequest?.instanceId === 'string' + ? placementRequest.instanceId + : '' + const placement = + placementRequest?.placement === 'bottom' ? 'bottom' : 'top' + + if (!roomContext || !roomContext.room.hasStarted || !instanceId) { + acknowledge?.({ ok: false, error: '덱 카드를 배치하지 못했습니다.' }) + return + } + + roomContext.player.privateDeckOrder = + roomContext.player.privateDeckOrder.filter( + (deckInstanceId) => deckInstanceId !== instanceId, + ) + + if (placement === 'bottom') { + roomContext.player.privateDeckOrder.unshift(instanceId) + } else { + roomContext.player.privateDeckOrder.push(instanceId) + } + + acknowledge?.({ ok: true }) + }) + + socket.on('game:remove-private-deck-card', (instanceId, acknowledge) => { + const roomContext = findSocketRoomContext(socket.id) + + if ( + !roomContext || + !roomContext.room.hasStarted || + typeof instanceId !== 'string' + ) { + acknowledge?.({ ok: false, error: '덱 카드를 제거하지 못했습니다.' }) + return + } + + roomContext.player.privateDeckOrder = + roomContext.player.privateDeckOrder.filter( + (deckInstanceId) => deckInstanceId !== instanceId, + ) + acknowledge?.({ ok: true }) + }) + + socket.on('game:end-turn', (acknowledge) => { + const roomContext = findSocketRoomContext(socket.id) + const gameState = roomContext?.room.gameState + + if (!roomContext || !roomContext.room.hasStarted || !gameState) { + acknowledge?.({ ok: false, error: '시작된 게임을 찾지 못했습니다.' }) + return + } + + if (gameState.currentTurnPlayerId !== roomContext.player.playerId) { + acknowledge?.({ ok: false, error: '현재 자신의 턴이 아닙니다.' }) + return + } + + const nextPlayer = Array.from(roomContext.room.players.values()).find( + (player) => player.playerId !== roomContext.player.playerId, + ) + if (!nextPlayer) { + acknowledge?.({ ok: false, error: '상대 플레이어가 연결되어 있지 않습니다.' }) + return + } + + gameState.currentTurnPlayerId = nextPlayer.playerId + gameState.turnSequence += 1 + acknowledge?.({ ok: true }) + broadcastGameState(socketServer, roomContext.room) + }) + + socket.on('room:leave', (acknowledge) => { + const roomContext = findSocketRoomContext(socket.id) + + if (!roomContext) { + acknowledge?.({ ok: true }) + return + } + + closeRoomForAllPlayers( + socketServer, + roomContext.room, + '한 플레이어가 방을 나가 방이 종료되었습니다.', + ) + acknowledge?.({ ok: true }) }) socket.on('disconnect', () => { - leaveCurrentRoom(socketServer, socket) + preserveDisconnectedPlayer(socketServer, socket) }) }) diff --git a/server/integration-test.js b/server/integration-test.js index b926fac..fa8dc6d 100644 --- a/server/integration-test.js +++ b/server/integration-test.js @@ -76,6 +76,19 @@ function waitForGameStart(socket) { }) } +/** + * 지정한 Socket.IO 이벤트를 한 번 기다린다. + * + * @param {import('socket.io-client').Socket} socket 이벤트를 기다릴 소켓 + * @param {string} eventName 기다릴 이벤트 이름 + * @returns {Promise} 수신한 이벤트 데이터 + */ +function waitForSocketEvent(socket, eventName) { + return new Promise((resolve) => { + socket.once(eventName, resolve) + }) +} + /** * 두 클라이언트의 방 생성부터 게임 시작까지 검증한다. * @@ -90,6 +103,7 @@ async function runIntegrationTest() { path: '/socket.io', transports: ['websocket', 'polling'], }) + let resumedSecondPlayerSocket = null try { await Promise.all([ @@ -149,11 +163,191 @@ async function runIntegrationTest() { assert.equal(secondStartPayload.seat, 'player-two') assert.equal(firstStartPayload.roomCode, secondStartPayload.roomCode) assert.match(firstStartPayload.gameQuery, /series=ua01/) + assert.ok( + [createRoomResponse.playerId, joinRoomResponse.playerId].includes( + firstStartPayload.gameState.firstPlayerId, + ), + ) - console.log('Socket.IO 2인 방 통합 테스트를 통과했습니다.') + const privateDeckInstanceIds = Array.from( + { length: 50 }, + (_, cardIndex) => `private-card-${cardIndex + 1}`, + ) + const shuffledDeckResponse = await emitWithAcknowledgement( + firstPlayerSocket, + 'game:shuffle-private-deck', + privateDeckInstanceIds, + ) + assert.equal(shuffledDeckResponse.ok, true) + assert.equal(shuffledDeckResponse.instanceIds.length, 50) + assert.equal(new Set(shuffledDeckResponse.instanceIds).size, 50) + + const drawnDeckResponse = await emitWithAcknowledgement( + firstPlayerSocket, + 'game:draw-private-deck', + 2, + ) + assert.deepEqual(drawnDeckResponse.instanceIds, [ + shuffledDeckResponse.instanceIds[49], + shuffledDeckResponse.instanceIds[48], + ]) + + const returnedCardInstanceId = drawnDeckResponse.instanceIds[0] + await emitWithAcknowledgement( + firstPlayerSocket, + 'game:place-private-deck-card', + { + instanceId: returnedCardInstanceId, + placement: 'top', + }, + ) + const redrawnDeckResponse = await emitWithAcknowledgement( + firstPlayerSocket, + 'game:draw-private-deck', + 1, + ) + assert.equal(redrawnDeckResponse.instanceIds[0], returnedCardInstanceId) + + await emitWithAcknowledgement( + firstPlayerSocket, + 'game:confirm-setup', + ) + const startedTurnStatePromise = waitForSocketEvent( + firstPlayerSocket, + 'game:state', + ) + await emitWithAcknowledgement( + secondPlayerSocket, + 'game:confirm-setup', + ) + const startedTurnState = await startedTurnStatePromise + assert.equal( + startedTurnState.currentTurnPlayerId, + startedTurnState.firstPlayerId, + ) + + const opponentStatePromise = waitForSocketEvent( + secondPlayerSocket, + 'game:opponent-state', + ) + await emitWithAcknowledgement( + firstPlayerSocket, + 'game:publish-player-state', + { + revision: 2, + cards: [ + { + instanceId: 'published-card-1', + cardNumber: 'TEST-001', + imageUrl: '/card-assets/ua01/TEST-001.png', + zone: 'field', + position: { x: 0.4, y: 0.7 }, + isFaceUp: true, + isTapped: false, + isUntapLocked: false, + stackOrder: 10, + }, + { + instanceId: 'hidden-hand-card', + cardNumber: 'SECRET-001', + imageUrl: '/card-assets/ua01/SECRET-001.png', + zone: 'hand', + position: { x: 0.5, y: 0.9 }, + isFaceUp: true, + isTapped: false, + isUntapLocked: false, + stackOrder: 11, + }, + ], + counts: { + deck: 35, + hand: 7, + life: 7, + }, + }, + ) + const opponentState = await opponentStatePromise + assert.equal(opponentState.cards.length, 1) + assert.equal(opponentState.cards[0].instanceId, 'published-card-1') + assert.equal(opponentState.counts.hand, 7) + assert.equal(opponentState.revision, 2) + + const staleStateResponse = await emitWithAcknowledgement( + firstPlayerSocket, + 'game:publish-player-state', + { + revision: 1, + cards: [], + counts: { + deck: 0, + hand: 0, + life: 0, + }, + }, + ) + assert.equal(staleStateResponse.ignored, true) + assert.equal(staleStateResponse.revision, 2) + + const currentTurnSocket = + startedTurnState.currentTurnPlayerId === createRoomResponse.playerId + ? firstPlayerSocket + : secondPlayerSocket + const nextTurnStatePromise = waitForSocketEvent( + firstPlayerSocket, + 'game:state', + ) + const endTurnResponse = await emitWithAcknowledgement( + currentTurnSocket, + 'game:end-turn', + ) + const nextTurnState = await nextTurnStatePromise + assert.equal(endTurnResponse.ok, true) + assert.notEqual( + nextTurnState.currentTurnPlayerId, + startedTurnState.currentTurnPlayerId, + ) + + secondPlayerSocket.disconnect() + resumedSecondPlayerSocket = io(SOCKET_SERVER_URL, { + path: '/socket.io', + transports: ['websocket', 'polling'], + }) + await waitForConnection(resumedSecondPlayerSocket) + const resumeResponse = await emitWithAcknowledgement( + resumedSecondPlayerSocket, + 'room:resume', + { + roomCode: joinRoomResponse.room.roomCode, + playerId: joinRoomResponse.playerId, + resumeToken: joinRoomResponse.resumeToken, + }, + ) + assert.equal(resumeResponse.ok, true) + assert.equal(resumeResponse.playerId, joinRoomResponse.playerId) + assert.equal(resumeResponse.room.hasStarted, true) + assert.equal( + resumeResponse.gameStartPayload.roomCode, + joinRoomResponse.room.roomCode, + ) + assert.match(resumeResponse.gameStartPayload.gameQuery, /series=ua01/) + + const roomClosedPromise = waitForSocketEvent( + firstPlayerSocket, + 'room:closed', + ) + const leaveRoomResponse = await emitWithAcknowledgement( + resumedSecondPlayerSocket, + 'room:leave', + ) + const roomClosedInformation = await roomClosedPromise + assert.equal(leaveRoomResponse.ok, true) + assert.match(roomClosedInformation.message, /방이 종료/) + + console.log('Socket.IO 2인 게임 통합 테스트를 통과했습니다.') } finally { firstPlayerSocket.disconnect() secondPlayerSocket.disconnect() + resumedSecondPlayerSocket?.disconnect() } } diff --git a/src/components/cards/GameCard.vue b/src/components/cards/GameCard.vue index 1b5693d..4a4fec0 100644 --- a/src/components/cards/GameCard.vue +++ b/src/components/cards/GameCard.vue @@ -14,6 +14,15 @@ const props = defineProps({ type: Boolean, default: false, }, + isInteractive: { + type: Boolean, + default: true, + }, + displayMode: { + type: String, + default: 'board', + validator: (displayMode) => ['board', 'hand'].includes(displayMode), + }, }) const emit = defineEmits([ @@ -24,12 +33,19 @@ const emit = defineEmits([ ]) const hasImageError = ref(false) -const positionStyle = computed(() => ({ - left: `${props.card.position.x * 100}%`, - top: `${props.card.position.y * 100}%`, - zIndex: props.card.stackOrder, - transform: `translate(-50%, -50%) rotate(${props.card.isTapped ? 90 : 0}deg)`, -})) +const positionStyle = computed(() => + props.displayMode === 'hand' + ? { + zIndex: props.card.stackOrder, + transform: `rotate(${props.card.isTapped ? 90 : 0}deg)`, + } + : { + left: `${props.card.position.x * 100}%`, + top: `${props.card.position.y * 100}%`, + zIndex: props.card.stackOrder, + transform: `translate(-50%, -50%) rotate(${props.card.isTapped ? 90 : 0}deg)`, + }, +) /** * 카드 포인터 입력을 플레이 보드 드래그 처리기로 전달한다. @@ -38,7 +54,7 @@ const positionStyle = computed(() => ({ * @returns {void} */ function startCardDrag(pointerEvent) { - if (pointerEvent.button !== 0) return + if (!props.isInteractive || pointerEvent.button !== 0) return emit('selected', props.card.instanceId) emit('drag-started', { @@ -63,6 +79,7 @@ function handleImageError() { * @returns {void} */ function handleDoubleClick(mouseEvent) { + if (!props.isInteractive) return mouseEvent.preventDefault() emit('double-clicked', props.card.instanceId) } @@ -74,6 +91,7 @@ function handleDoubleClick(mouseEvent) { * @returns {void} */ function requestContextMenu(mouseEvent) { + if (!props.isInteractive) return mouseEvent.preventDefault() emit('selected', props.card.instanceId) emit('context-menu-requested', { @@ -87,14 +105,19 @@ function requestContextMenu(mouseEvent) {