feat: add multiplayer lobby and game board

This commit is contained in:
2026-06-19 16:13:55 +09:00
parent bb5f663e48
commit 1af3c71f8a
29 changed files with 4156 additions and 148 deletions

281
server/index.js Normal file
View 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
View 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()