feat: add multiplayer lobby and game board
This commit is contained in:
281
server/index.js
Normal file
281
server/index.js
Normal file
@@ -0,0 +1,281 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { randomInt } from 'node:crypto'
|
||||
import { Server } from 'socket.io'
|
||||
import { validateDeckRegistrationUrl } from '../src/services/deckRegistration.js'
|
||||
|
||||
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 rooms = new Map()
|
||||
const socketRoomCodes = new Map()
|
||||
|
||||
/**
|
||||
* 사람이 읽기 쉬운 비공개 방 코드를 생성한다.
|
||||
*
|
||||
* @returns {string} 현재 방 목록과 겹치지 않는 방 코드
|
||||
*/
|
||||
function createUniqueRoomCode() {
|
||||
let roomCode = ''
|
||||
|
||||
do {
|
||||
roomCode = Array.from({ length: ROOM_CODE_LENGTH }, () => {
|
||||
const characterIndex = randomInt(ROOM_CODE_CHARACTERS.length)
|
||||
return ROOM_CODE_CHARACTERS[characterIndex]
|
||||
}).join('')
|
||||
} while (rooms.has(roomCode))
|
||||
|
||||
return roomCode
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트에 공개할 안전한 방 상태를 만든다.
|
||||
*
|
||||
* @param {object} room 방 상태
|
||||
* @returns {object} 덱 상세 정보를 제외한 방 상태
|
||||
*/
|
||||
function createPublicRoomState(room) {
|
||||
return {
|
||||
roomCode: room.roomCode,
|
||||
hasStarted: room.hasStarted,
|
||||
players: Array.from(room.players.values()).map((player) => ({
|
||||
playerId: player.playerId,
|
||||
seat: player.seat,
|
||||
isConnected: true,
|
||||
isReady: player.isReady,
|
||||
hasRegisteredDeck: Boolean(player.deckRegistration?.isValid),
|
||||
deckSeriesName: player.deckRegistration?.seriesName || '',
|
||||
deckCardCount: player.deckRegistration?.totalCardCount || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 방 참가자 모두에게 최신 공개 상태를 전송한다.
|
||||
*
|
||||
* @param {Server} socketServer Socket.IO 서버
|
||||
* @param {object} room 방 상태
|
||||
* @returns {void}
|
||||
*/
|
||||
function broadcastRoomState(socketServer, room) {
|
||||
socketServer
|
||||
.to(room.roomCode)
|
||||
.emit('room:state', createPublicRoomState(room))
|
||||
}
|
||||
|
||||
/**
|
||||
* 소켓이 현재 참가 중인 방과 플레이어 상태를 찾는다.
|
||||
*
|
||||
* @param {string} socketId 소켓 식별자
|
||||
* @returns {{ room: object, player: object } | null} 방과 플레이어 상태
|
||||
*/
|
||||
function findSocketRoomContext(socketId) {
|
||||
const roomCode = socketRoomCodes.get(socketId)
|
||||
const room = roomCode ? rooms.get(roomCode) : null
|
||||
const player = room?.players.get(socketId)
|
||||
|
||||
return room && player ? { room, player } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 소켓을 기존 방에서 제거하고 빈 방을 정리한다.
|
||||
*
|
||||
* @param {Server} socketServer Socket.IO 서버
|
||||
* @param {import('socket.io').Socket} socket 방을 나갈 소켓
|
||||
* @returns {void}
|
||||
*/
|
||||
function leaveCurrentRoom(socketServer, socket) {
|
||||
const socketId = socket.id
|
||||
const roomCode = socketRoomCodes.get(socketId)
|
||||
const room = roomCode ? rooms.get(roomCode) : null
|
||||
|
||||
socketRoomCodes.delete(socketId)
|
||||
if (roomCode) {
|
||||
socket.leave(roomCode)
|
||||
}
|
||||
if (!room) return
|
||||
|
||||
room.players.delete(socketId)
|
||||
|
||||
if (room.players.size === 0) {
|
||||
rooms.delete(roomCode)
|
||||
return
|
||||
}
|
||||
|
||||
room.hasStarted = false
|
||||
broadcastRoomState(socketServer, room)
|
||||
}
|
||||
|
||||
/**
|
||||
* 양쪽 플레이어가 준비되면 각자 자신의 덱 정보로 게임 시작을 알린다.
|
||||
*
|
||||
* @param {Server} socketServer Socket.IO 서버
|
||||
* @param {object} room 방 상태
|
||||
* @returns {void}
|
||||
*/
|
||||
function startRoomGameWhenReady(socketServer, room) {
|
||||
const players = Array.from(room.players.values())
|
||||
const canStartGame =
|
||||
players.length === MAXIMUM_ROOM_PLAYERS &&
|
||||
players.every(
|
||||
(player) => player.isReady && player.deckRegistration?.isValid,
|
||||
)
|
||||
|
||||
if (!canStartGame || room.hasStarted) return
|
||||
|
||||
room.hasStarted = true
|
||||
broadcastRoomState(socketServer, room)
|
||||
|
||||
players.forEach((player) => {
|
||||
socketServer.to(player.playerId).emit('game:started', {
|
||||
roomCode: room.roomCode,
|
||||
seat: player.seat,
|
||||
gameQuery: player.deckRegistration.gameQuery,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const httpServer = createServer((request, response) => {
|
||||
if (request.url === '/health') {
|
||||
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
||||
response.end('ok')
|
||||
return
|
||||
}
|
||||
|
||||
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
|
||||
response.end('not found')
|
||||
})
|
||||
|
||||
const socketServer = new Server(httpServer, {
|
||||
path: '/socket.io',
|
||||
})
|
||||
|
||||
socketServer.on('connection', (socket) => {
|
||||
socket.on('room:create', (acknowledge) => {
|
||||
leaveCurrentRoom(socketServer, socket)
|
||||
|
||||
const roomCode = createUniqueRoomCode()
|
||||
const room = {
|
||||
roomCode,
|
||||
hasStarted: false,
|
||||
players: new Map(),
|
||||
}
|
||||
const player = {
|
||||
playerId: socket.id,
|
||||
seat: 'player-one',
|
||||
isReady: false,
|
||||
deckRegistration: null,
|
||||
}
|
||||
|
||||
room.players.set(socket.id, player)
|
||||
rooms.set(roomCode, room)
|
||||
socketRoomCodes.set(socket.id, roomCode)
|
||||
socket.join(roomCode)
|
||||
|
||||
acknowledge?.({
|
||||
ok: true,
|
||||
playerId: socket.id,
|
||||
room: createPublicRoomState(room),
|
||||
})
|
||||
broadcastRoomState(socketServer, room)
|
||||
})
|
||||
|
||||
socket.on('room:join', (requestedRoomCode, acknowledge) => {
|
||||
const roomCode =
|
||||
typeof requestedRoomCode === 'string'
|
||||
? requestedRoomCode.trim().toUpperCase()
|
||||
: ''
|
||||
const room = rooms.get(roomCode)
|
||||
|
||||
if (!room) {
|
||||
acknowledge?.({ ok: false, error: '존재하지 않는 방 코드입니다.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (room.hasStarted) {
|
||||
acknowledge?.({ ok: false, error: '이미 게임이 시작된 방입니다.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (room.players.size >= MAXIMUM_ROOM_PLAYERS) {
|
||||
acknowledge?.({ ok: false, error: '방 인원이 가득 찼습니다.' })
|
||||
return
|
||||
}
|
||||
|
||||
leaveCurrentRoom(socketServer, socket)
|
||||
|
||||
const player = {
|
||||
playerId: socket.id,
|
||||
seat: 'player-two',
|
||||
isReady: false,
|
||||
deckRegistration: null,
|
||||
}
|
||||
|
||||
room.players.set(socket.id, player)
|
||||
socketRoomCodes.set(socket.id, roomCode)
|
||||
socket.join(roomCode)
|
||||
|
||||
acknowledge?.({
|
||||
ok: true,
|
||||
playerId: socket.id,
|
||||
room: createPublicRoomState(room),
|
||||
})
|
||||
broadcastRoomState(socketServer, room)
|
||||
})
|
||||
|
||||
socket.on('player:register-deck', (deckUrlText, acknowledge) => {
|
||||
const roomContext = findSocketRoomContext(socket.id)
|
||||
|
||||
if (!roomContext) {
|
||||
acknowledge?.({ ok: false, error: '먼저 방에 참가해주세요.' })
|
||||
return
|
||||
}
|
||||
|
||||
const deckRegistration = validateDeckRegistrationUrl(
|
||||
typeof deckUrlText === 'string' ? deckUrlText : '',
|
||||
)
|
||||
|
||||
roomContext.player.deckRegistration = deckRegistration.isValid
|
||||
? deckRegistration
|
||||
: null
|
||||
roomContext.player.isReady = false
|
||||
|
||||
acknowledge?.({
|
||||
ok: deckRegistration.isValid,
|
||||
validation: deckRegistration,
|
||||
})
|
||||
broadcastRoomState(socketServer, roomContext.room)
|
||||
})
|
||||
|
||||
socket.on('player:set-ready', (requestedReadyState, acknowledge) => {
|
||||
const roomContext = findSocketRoomContext(socket.id)
|
||||
|
||||
if (!roomContext) {
|
||||
acknowledge?.({ ok: false, error: '먼저 방에 참가해주세요.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (requestedReadyState && !roomContext.player.deckRegistration?.isValid) {
|
||||
acknowledge?.({ ok: false, error: '유효한 덱을 먼저 등록해주세요.' })
|
||||
return
|
||||
}
|
||||
|
||||
roomContext.player.isReady = Boolean(requestedReadyState)
|
||||
acknowledge?.({ ok: true })
|
||||
broadcastRoomState(socketServer, roomContext.room)
|
||||
startRoomGameWhenReady(socketServer, roomContext.room)
|
||||
})
|
||||
|
||||
socket.on('room:leave', () => {
|
||||
leaveCurrentRoom(socketServer, socket)
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
leaveCurrentRoom(socketServer, socket)
|
||||
})
|
||||
})
|
||||
|
||||
httpServer.listen(SERVER_PORT, '0.0.0.0', () => {
|
||||
console.log(`Play Uniare Socket.IO server listening on port ${SERVER_PORT}`)
|
||||
})
|
||||
160
server/integration-test.js
Normal file
160
server/integration-test.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { io } from 'socket.io-client'
|
||||
|
||||
const SOCKET_SERVER_PORT = process.env.SOCKET_SERVER_PORT || '31873'
|
||||
const SOCKET_SERVER_URL = `http://127.0.0.1:${SOCKET_SERVER_PORT}`
|
||||
|
||||
/**
|
||||
* 테스트에 사용할 유효한 50장 덱 URL을 만든다.
|
||||
*
|
||||
* @returns {string} 덱 등록용 결과 페이지 URL
|
||||
*/
|
||||
function createValidDeckUrl() {
|
||||
const cards = Array.from({ length: 13 }, (_, cardIndex) => ({
|
||||
imgSrc: `/ua01/TEST-${String(cardIndex + 1).padStart(3, '0')}.png`,
|
||||
Number: `TEST-${String(cardIndex + 1).padStart(3, '0')}`,
|
||||
count: cardIndex === 12 ? 2 : 4,
|
||||
color: 'Blue',
|
||||
trigger: null,
|
||||
}))
|
||||
const deckUrl = new URL('http://uniare.sori.studio/result.html')
|
||||
|
||||
deckUrl.searchParams.set('series', 'ua01')
|
||||
deckUrl.searchParams.set('seriesName', 'Code Geass')
|
||||
deckUrl.searchParams.set('cards', JSON.stringify(cards))
|
||||
|
||||
return deckUrl.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 완료 또는 오류를 Promise로 기다린다.
|
||||
*
|
||||
* @param {import('socket.io-client').Socket} socket 확인할 클라이언트 소켓
|
||||
* @returns {Promise<void>} 연결 완료 Promise
|
||||
*/
|
||||
function waitForConnection(socket) {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once('connect', resolve)
|
||||
socket.once('connect_error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket.IO 승인 응답이 있는 이벤트를 전송한다.
|
||||
*
|
||||
* @param {import('socket.io-client').Socket} socket 이벤트를 전송할 소켓
|
||||
* @param {string} eventName 이벤트 이름
|
||||
* @param {unknown[]} eventArguments 이벤트 인자
|
||||
* @returns {Promise<object>} 서버 승인 응답
|
||||
*/
|
||||
function emitWithAcknowledgement(socket, eventName, ...eventArguments) {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.timeout(3000).emit(
|
||||
eventName,
|
||||
...eventArguments,
|
||||
(timeoutError, response) => {
|
||||
if (timeoutError) {
|
||||
reject(timeoutError)
|
||||
return
|
||||
}
|
||||
|
||||
resolve(response)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 게임 시작 이벤트를 한 번 기다린다.
|
||||
*
|
||||
* @param {import('socket.io-client').Socket} socket 이벤트를 기다릴 소켓
|
||||
* @returns {Promise<object>} 게임 시작 정보
|
||||
*/
|
||||
function waitForGameStart(socket) {
|
||||
return new Promise((resolve) => {
|
||||
socket.once('game:started', resolve)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 클라이언트의 방 생성부터 게임 시작까지 검증한다.
|
||||
*
|
||||
* @returns {Promise<void>} 검증 완료 Promise
|
||||
*/
|
||||
async function runIntegrationTest() {
|
||||
const firstPlayerSocket = io(SOCKET_SERVER_URL, {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling'],
|
||||
})
|
||||
const secondPlayerSocket = io(SOCKET_SERVER_URL, {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling'],
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
waitForConnection(firstPlayerSocket),
|
||||
waitForConnection(secondPlayerSocket),
|
||||
])
|
||||
|
||||
const createRoomResponse = await emitWithAcknowledgement(
|
||||
firstPlayerSocket,
|
||||
'room:create',
|
||||
)
|
||||
assert.equal(createRoomResponse.ok, true)
|
||||
assert.equal(createRoomResponse.room.players.length, 1)
|
||||
|
||||
const joinRoomResponse = await emitWithAcknowledgement(
|
||||
secondPlayerSocket,
|
||||
'room:join',
|
||||
createRoomResponse.room.roomCode,
|
||||
)
|
||||
assert.equal(joinRoomResponse.ok, true)
|
||||
assert.equal(joinRoomResponse.room.players.length, 2)
|
||||
|
||||
const validDeckUrl = createValidDeckUrl()
|
||||
const firstDeckResponse = await emitWithAcknowledgement(
|
||||
firstPlayerSocket,
|
||||
'player:register-deck',
|
||||
validDeckUrl,
|
||||
)
|
||||
const secondDeckResponse = await emitWithAcknowledgement(
|
||||
secondPlayerSocket,
|
||||
'player:register-deck',
|
||||
validDeckUrl,
|
||||
)
|
||||
assert.equal(firstDeckResponse.ok, true)
|
||||
assert.equal(secondDeckResponse.ok, true)
|
||||
|
||||
const firstGameStart = waitForGameStart(firstPlayerSocket)
|
||||
const secondGameStart = waitForGameStart(secondPlayerSocket)
|
||||
|
||||
await emitWithAcknowledgement(
|
||||
firstPlayerSocket,
|
||||
'player:set-ready',
|
||||
true,
|
||||
)
|
||||
await emitWithAcknowledgement(
|
||||
secondPlayerSocket,
|
||||
'player:set-ready',
|
||||
true,
|
||||
)
|
||||
|
||||
const [firstStartPayload, secondStartPayload] = await Promise.all([
|
||||
firstGameStart,
|
||||
secondGameStart,
|
||||
])
|
||||
|
||||
assert.equal(firstStartPayload.seat, 'player-one')
|
||||
assert.equal(secondStartPayload.seat, 'player-two')
|
||||
assert.equal(firstStartPayload.roomCode, secondStartPayload.roomCode)
|
||||
assert.match(firstStartPayload.gameQuery, /series=ua01/)
|
||||
|
||||
console.log('Socket.IO 2인 방 통합 테스트를 통과했습니다.')
|
||||
} finally {
|
||||
firstPlayerSocket.disconnect()
|
||||
secondPlayerSocket.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
await runIntegrationTest()
|
||||
Reference in New Issue
Block a user