Files

799 lines
23 KiB
JavaScript

import { createServer } from 'node:http'
import { randomInt, randomUUID } 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 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()
/**
* 사람이 읽기 쉬운 비공개 방 코드를 생성한다.
*
* @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: player.isConnected,
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 {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,
},
},
)
}
/**
* 소켓이 현재 참가 중인 방과 플레이어 상태를 찾는다.
*
* @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 playerId = socketPlayerIds.get(socketId)
const player = playerId ? room?.players.get(playerId) : null
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
const playerId = socketPlayerIds.get(socketId)
socketRoomCodes.delete(socketId)
socketPlayerIds.delete(socketId)
if (roomCode) {
socket.leave(roomCode)
}
if (!room || !playerId) return
const player = room.players.get(playerId)
if (player?.disconnectTimer) {
clearTimeout(player.disconnectTimer)
}
room.players.delete(playerId)
if (room.players.size === 0) {
rooms.delete(roomCode)
return
}
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)
}
/**
* 양쪽 플레이어가 준비되면 각자 자신의 덱 정보로 게임 시작을 알린다.
*
* @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
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.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) => {
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 playerId = randomUUID()
const resumeToken = randomUUID()
const room = {
roomCode,
hasStarted: false,
players: new Map(),
}
const player = {
playerId,
resumeToken,
socketId: socket.id,
seat: 'player-one',
isConnected: true,
isReady: false,
deckRegistration: null,
publicCards: [],
gameCounts: null,
publicStateRevision: 0,
privateDeckOrder: [],
}
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,
resumeToken,
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 playerId = randomUUID()
const resumeToken = randomUUID()
const player = {
playerId,
resumeToken,
socketId: socket.id,
seat: 'player-two',
isConnected: true,
isReady: false,
deckRegistration: null,
publicCards: [],
gameCounts: null,
publicStateRevision: 0,
privateDeckOrder: [],
}
room.players.set(playerId, player)
socketRoomCodes.set(socket.id, roomCode)
socketPlayerIds.set(socket.id, playerId)
socket.join(roomCode)
acknowledge?.({
ok: true,
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)
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('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', () => {
preserveDisconnectedPlayer(socketServer, socket)
})
})
httpServer.listen(SERVER_PORT, '0.0.0.0', () => {
console.log(`Play Uniare Socket.IO server listening on port ${SERVER_PORT}`)
})