799 lines
23 KiB
JavaScript
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}`)
|
|
})
|