282 lines
7.5 KiB
JavaScript
282 lines
7.5 KiB
JavaScript
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}`)
|
|
})
|