diff --git a/drizzle/0001_wakeful_silver_fox.sql b/drizzle/0001_wakeful_silver_fox.sql new file mode 100644 index 0000000..b2f8b05 --- /dev/null +++ b/drizzle/0001_wakeful_silver_fox.sql @@ -0,0 +1,14 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_users` ( + `id` text PRIMARY KEY NOT NULL, + `email` text NOT NULL, + `password_hash` text NOT NULL, + `nickname` text NOT NULL, + `created_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_users`("id", "email", "password_hash", "nickname", "created_at") SELECT "id", "email", "password_hash", "nickname", "created_at" FROM `users`;--> statement-breakpoint +DROP TABLE `users`;--> statement-breakpoint +ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..0bb97fc --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,72 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2f463a59-2377-49d8-992b-f8d2a2c60a49", + "prevId": "83c12a3c-ec11-4135-b526-261771d6ede4", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9eebdf4..a1ecf6d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1763435039243, "tag": "0000_omniscient_lady_mastermind", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1763601535973, + "tag": "0001_wakeful_silver_fox", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 9b038e4..e0075d3 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ ] }, "dependencies": { + "@number-flow/svelte": "^0.3.9", "drizzle-orm": "^0.44.7", "jose": "^6.1.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89e715c..6ee684e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@number-flow/svelte': + specifier: ^0.3.9 + version: 0.3.9(svelte@5.43.8) drizzle-orm: specifier: ^0.44.7 version: 0.44.7(@cloudflare/workers-types@4.20251117.0)(@libsql/client@0.15.15)(better-sqlite3@12.4.1) @@ -759,6 +762,11 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@number-flow/svelte@0.3.9': + resolution: {integrity: sha512-CTw1+e0074GzbPX2IHcNCaK8nqxGNCOIUnQUjEjhcmBwBxOAhN3GYLQ6cJHvhQnWwplVe4eQ3z+c25Vttr2stQ==} + peerDependencies: + svelte: ^4 || ^5 + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1487,6 +1495,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + number-flow@0.5.8: + resolution: {integrity: sha512-FPr1DumWyGi5Nucoug14bC6xEz70A1TnhgSHhKyfqjgji2SOTz+iLJxKtv37N5JyJbteGYCm6NQ9p1O4KZ7iiA==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2255,6 +2266,12 @@ snapshots: '@neon-rs/load@0.0.4': {} + '@number-flow/svelte@0.3.9(svelte@5.43.8)': + dependencies: + esm-env: 1.2.2 + number-flow: 0.5.8 + svelte: 5.43.8 + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.5': @@ -2849,6 +2866,10 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + number-flow@0.5.8: + dependencies: + esm-env: 1.2.2 + once@1.4.0: dependencies: wrappy: 1.0.2 diff --git a/src/app.d.ts b/src/app.d.ts index b668225..aed5213 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -14,7 +14,7 @@ declare global { } interface Locals { user?: { - id: number; + id: string; email: string; nickname: string; }; diff --git a/src/lib/counter-do.ts b/src/lib/counter-do.ts index 179c5df..fb3a157 100644 --- a/src/lib/counter-do.ts +++ b/src/lib/counter-do.ts @@ -1,7 +1,8 @@ import type { DurableObjectNamespace, DurableObjectState } from '@cloudflare/workers-types'; -import type { Session } from './types'; import { applyBetResults } from './game-results'; import { DB_SCHEMA, type UserRecord, type CurrentBetRecord } from './db-schema'; +import { verifyToken } from './server/auth'; +import type { BettingInfo, BetTypeKey } from './types'; // 게임 지속 시간 상수 (ms) export const NO_MORE_BET_DURATION_MS = 15_000; // 15초 @@ -11,11 +12,22 @@ export interface Env { COUNTER: DurableObjectNamespace; } +// 세션 인터페이스 (Durable Object용) +export interface Session { + id: string; + webSocket: WebSocket; + userId: string | undefined; // DB user.id (UUID) + nickname: string | undefined; + capital: number | undefined; + betInfo: BettingInfo[]; // 배팅 정보 배열 +} + export class CounterDurableObject { private ctx: DurableObjectState; private env: Env; private sessions: Map; + private userSessions: Map; // userId -> WebSocket 매핑 private sql: any; // SqlStorage 타입 // 주사위 게임 상태 @@ -32,6 +44,7 @@ export class CounterDurableObject { this.ctx = ctx; this.env = env; this.sessions = new Map(); + this.userSessions = new Map(); // SQLite 초기화 this.sql = ctx.storage.sql; @@ -121,6 +134,64 @@ export class CounterDurableObject { } } + if (url.pathname === '/sql/clear-users') { + // user 테이블 데이터 모두 삭제 (개발용) + if (request.method === 'POST') { + try { + this.sql.exec('DELETE FROM user'); + return new Response(JSON.stringify({ success: true, message: 'All users deleted' }), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + } + } + + if (url.pathname === '/sql/migrate-remove-nickname') { + // nickname 컬럼 제거 마이그레이션 (개발용) + if (request.method === 'POST') { + try { + // 1. 새 테이블 생성 + this.sql.exec(` + CREATE TABLE IF NOT EXISTS user_new ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + joinGameCount INTEGER DEFAULT 0, + capital INTEGER DEFAULT 10000 + ) + `); + + // 2. 데이터 복사 (nickname 제외) + this.sql.exec(` + INSERT INTO user_new (id, email, joinGameCount, capital) + SELECT id, email, joinGameCount, capital FROM user + `); + + // 3. 기존 테이블 삭제 + this.sql.exec('DROP TABLE user'); + + // 4. 새 테이블 이름 변경 + this.sql.exec('ALTER TABLE user_new RENAME TO user'); + + return new Response(JSON.stringify({ + success: true, + message: 'Migration completed: nickname column removed' + }), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + } + } + // WebSocket 업그레이드 요청 처리 const upgradeHeader = request.headers.get('Upgrade'); if (!upgradeHeader || upgradeHeader !== 'websocket') { @@ -135,17 +206,10 @@ export class CounterDurableObject { const session: Session = { id: crypto.randomUUID(), webSocket: server, + userId: undefined, nickname: undefined, capital: undefined, - oddBet: 0, - evenBet: 0, - bigBet: 0, - smallBet: 0, - oddResult: null, - evenResult: null, - bigResult: null, - smallResult: null, - lastWinAmount: 0 + betInfo: [] // 배팅 정보 배열 }; // WebSocket Hibernation API 사용 @@ -153,11 +217,6 @@ export class CounterDurableObject { this.ctx.acceptWebSocket(server); this.sessions.set(server, session); - // 첫 접속자가 들어왔을 때 게임 루프 시작 - if (this.sessions.size === 1) { - this.startGameLoop(); - } - // 현재 상태 전송 this.broadcast(); @@ -173,14 +232,78 @@ export class CounterDurableObject { const data = typeof message === 'string' ? JSON.parse(message) : null; const session = this.sessions.get(ws); - if (!session) return; + if (!session) return; - if (data && data.type === 'setUser') { - // 사용자 정보 설정 - session.nickname = data.nickname; - session.capital = data.capital; - this.broadcast(); - } else if (data && data.type === 'sqlQuery') { + if (data && data.type === 'setUser') { + // JWT 토큰 검증 + const token = data.token; + if (!token) { + console.error('Missing JWT token'); + return; + } + + try { + // JWT 토큰 검증 및 페이로드 추출 + const payload = await verifyToken(token); + if (!payload) { + console.error('Invalid JWT token'); + return; + } + + const userId = payload.userId; + const email = payload.email; + + // SQLite에서 사용자 정보 조회 + let user = this.getUser(userId); + + if (!user) { + // 사용자가 없으면 새로 생성 (초기 자본금 10000) + this.createOrUpdateUser(userId, email, 10000); + user = this.getUser(userId); + console.log(`New user created: ${email}, Capital: 10000`); + } + + if (user) { + // 동일 사용자의 기존 WebSocket 연결 확인 + const existingWs = this.userSessions.get(user.id); + if (existingWs && existingWs !== ws) { + // 기존 연결에 중복 로그인 알림 전송 + try { + if (existingWs.readyState === WebSocket.OPEN || existingWs.readyState === 1) { + existingWs.send(JSON.stringify({ + type: 'duplicateLogin', + message: '다른 브라우저에서 로그인되어 현재 연결이 종료됩니다.' + })); + } + // 기존 연결 종료 + setTimeout(() => { + this.sessions.delete(existingWs); + // Cloudflare Workers에서는 close() 인자 없이 호출 + try { + existingWs.close(); + } catch (e) { + console.error('Error closing WebSocket:', e); + } + }, 500); // 메시지 전송 후 0.5초 대기 + } catch (error) { + console.error('Error closing existing connection:', error); + } + } + + // 세션에 사용자 정보 설정 + session.userId = user.id; + session.nickname = user.email.split('@')[0]; // email에서 닉네임 추출 + session.capital = user.capital; + + // 새로운 연결을 userSessions에 등록 + this.userSessions.set(user.id, ws); + + console.log(`User loaded: ${user.email}, Capital: ${user.capital}`); + } this.broadcast(); + } catch (error) { + console.error('Error verifying token:', error); + } + } else if (data && data.type === 'sqlQuery') { // SQL 조회 요청 처리 try { let result; @@ -209,27 +332,57 @@ export class CounterDurableObject { ws.send(JSON.stringify({ type: 'sqlError', error: error.message })); } } else if (data && data.type === 'bet' && !this.noMoreBet) { - // 배팅 처리 + // 배팅 처리 - betInfo 배열에 추가 및 current_bet 테이블에 저장 const amount = data.amount || 1000; + const betType = data.betType as BetTypeKey; // 'Odd', 'Even', 'Big', 'Small' - if (session.capital && session.capital >= amount) { - switch (data.betType) { - case 'odd': - session.oddBet += amount; - break; - case 'even': - session.evenBet += amount; - break; - case 'big': - session.bigBet += amount; - break; - case 'small': - session.smallBet += amount; - break; - } - // 클라이언트 측에서 자본금 차감은 이미 되어있으므로 서버에서도 동기화 + if (!session.userId || session.capital === undefined) { + console.error('User not set in session'); + return; + } + + if (!this.gameId) { + console.error('No active game'); + return; + } + + if (session.capital >= amount) { + // 사용자 정보 조회 (userId로 직접 조회) + const user = this.getUser(session.userId); + if (!user) { + console.error('User not found in database'); + return; + } // 세션의 betInfo 배열에 추가 + const bettingInfo: BettingInfo = { + gameId: this.gameId, + betType: betType, + betMoney: amount, + isWin: undefined, + winMoney: undefined, + valid: true + }; + session.betInfo.push(bettingInfo); + + // current_bet 테이블에 배팅 저장 + const bet: CurrentBetRecord = { + gameId: this.gameId, + diceNum: 0, // 아직 주사위가 나오지 않음 + userId: user.id, + betType: betType, + amount: amount, + isWin: 0, + reward: 0, + valid: 1 + }; + this.saveBet(bet); + + // 자본금 차감 session.capital -= amount; - this.broadcast(); + // DB에도 자본금 업데이트 + this.updateUserCapital(user.id, session.capital); + + // 해당 사용자에게만 업데이트 전송 (배팅 정보, 자본금) + this.sendToSession(ws); } } } catch (error) { @@ -244,17 +397,76 @@ export class CounterDurableObject { */ private initializeDatabase() { try { - this.sql.exec(DB_SCHEMA); - console.log('SQLite database initialized successfully'); + // 기존 user 테이블에 nickname 컬럼이 있는지 확인 + const hasNickname = this.checkIfNicknameExists(); + + if (hasNickname) { + console.log('Migrating: Removing nickname column from user table...'); + + // 외래 키 체크 비활성화 + this.sql.exec('PRAGMA foreign_keys = OFF'); + + // 기존 user_new 테이블이 있으면 삭제 + try { + this.sql.exec('DROP TABLE IF EXISTS user_new'); + } catch (e) { + // 무시 + } + + // nickname 컬럼 제거 마이그레이션 + this.sql.exec(` + CREATE TABLE user_new ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + joinGameCount INTEGER DEFAULT 0, + capital INTEGER DEFAULT 10000 + ) + `); + + this.sql.exec(` + INSERT INTO user_new (id, email, joinGameCount, capital) + SELECT id, email, joinGameCount, capital FROM user + `); + + this.sql.exec('DROP TABLE user'); + this.sql.exec('ALTER TABLE user_new RENAME TO user'); + + // 외래 키 체크 재활성화 + this.sql.exec('PRAGMA foreign_keys = ON'); + + console.log('Migration completed: nickname column removed'); + } else { + // 새 스키마로 테이블 생성 + this.sql.exec(DB_SCHEMA); + console.log('SQLite database initialized successfully'); + } } catch (error) { console.error('Error initializing database:', error); + // 오류 발생 시 외래 키 체크 재활성화 + try { + this.sql.exec('PRAGMA foreign_keys = ON'); + } catch (e) { + // 무시 + } + } + } + + /** + * user 테이블에 nickname 컬럼이 존재하는지 확인 + */ + private checkIfNicknameExists(): boolean { + try { + const tableInfo = this.sql.exec("PRAGMA table_info(user)").toArray(); + return tableInfo.some((col: any) => col.name === 'nickname'); + } catch (error) { + return false; } } /** * 사용자 생성 또는 업데이트 */ - createOrUpdateUser(userId: string, nickname: string, email: string, capital?: number): void { + createOrUpdateUser(userId: string, email: string, capital?: number): void { try { // 기존 사용자 확인 const existing = this.sql.exec( @@ -265,8 +477,7 @@ export class CounterDurableObject { if (existing) { // 업데이트 this.sql.exec( - 'UPDATE user SET nickname = ?, email = ?, capital = COALESCE(?, capital) WHERE id = ?', - nickname, + 'UPDATE user SET email = ?, capital = COALESCE(?, capital) WHERE id = ?', email, capital, userId @@ -274,9 +485,8 @@ export class CounterDurableObject { } else { // 새로운 사용자 생성 this.sql.exec( - 'INSERT INTO user (id, nickname, email, capital) VALUES (?, ?, ?, ?)', + 'INSERT INTO user (id, email, capital) VALUES (?, ?, ?)', userId, - nickname, email, capital || 10000 ); @@ -285,9 +495,8 @@ export class CounterDurableObject { // 사용자가 없으면 생성 try { this.sql.exec( - 'INSERT INTO user (id, nickname, email, capital) VALUES (?, ?, ?, ?)', + 'INSERT INTO user (id, email, capital) VALUES (?, ?, ?)', userId, - nickname, email, capital || 10000 ); @@ -434,13 +643,127 @@ export class CounterDurableObject { this.noMoreBetStartTime = Date.now(); this.noMoreBetEndTime = this.noMoreBetStartTime + NO_MORE_BET_DURATION_MS; // 15초 + // 베팅 종료 로그 + const now = new Date(); + console.log(`[Betting Closed] GameID: ${this.gameId} | Time: ${now.toLocaleTimeString('ko-KR')} (${now.toISOString()})`); + // 3개의 주사위 랜덤 생성 (1-6) this.dice1 = Math.floor(Math.random() * 6) + 1; this.dice2 = Math.floor(Math.random() * 6) + 1; this.dice3 = Math.floor(Math.random() * 6) + 1; - // 추출된 함수로 배팅 결과 계산 및 세션 갱신 - applyBetResults(this.sessions, this.dice1, this.dice2, this.dice3); + // 주사위 합계 계산 + const sum = this.dice1 + this.dice2 + this.dice3; + const isOdd = sum % 2 === 1; + const isBig = sum >= 10; + + // 통계 변수 + let totalBetAmount = 0; + let totalRewardAmount = 0; + + // current_bet 테이블에서 현재 게임의 배팅 조회 + if (this.gameId) { + const bets = this.getBetsByGameId(this.gameId); + + // 각 배팅의 결과 계산 및 업데이트 + bets.forEach((bet) => { + let isWin = 0; + let reward = 0; + + // 배팅 타입에 따른 승패 판정 + switch (bet.betType) { + case 'odd': + if (isOdd) { + isWin = 1; + reward = bet.amount * 2; + } + break; + case 'even': + if (!isOdd) { + isWin = 1; + reward = bet.amount * 2; + } + break; + case 'big': + if (isBig) { + isWin = 1; + reward = bet.amount * 2; + } + break; + case 'small': + if (!isBig) { + isWin = 1; + reward = bet.amount * 2; + } + break; + } + + // 통계 누적 + totalBetAmount += bet.amount; + totalRewardAmount += reward; + + // diceNum 및 결과 업데이트 + this.sql.exec( + 'UPDATE current_bet SET diceNum = ?, isWin = ?, reward = ? WHERE id = ?', + sum, + isWin, + reward, + bet.id + ); + + // 승리 시 사용자 자본금 업데이트 + if (isWin) { + const user = this.getUser(bet.userId); + if (user) { + this.updateUserCapital(bet.userId, user.capital + reward); + } + } + }); + + // 게임 결과 로그 + const houseProfit = totalBetAmount - totalRewardAmount; + const resultType = `${isOdd ? 'ODD' : 'EVEN'}, ${isBig ? 'BIG' : 'SMALL'}`; + console.log( + `[Game Result] GameID: ${this.gameId} | Dice: [${this.dice1},${this.dice2},${this.dice3}] Sum: ${sum} (${resultType}) | ` + + `Total Bets: ${totalBetAmount.toLocaleString()}원 | Total Rewards: ${totalRewardAmount.toLocaleString()}원 | ` + + `House Profit: ${houseProfit >= 0 ? '+' : ''}${houseProfit.toLocaleString()}원 | Bet Count: ${bets.length}` + ); + + // 세션 betInfo 업데이트 (UI 표시용) + this.sessions.forEach((session) => { + if (!session.userId) return; + + // 사용자 정보 조회 + const user = this.getUser(session.userId); + if (!user) return; + + // 현재 게임의 betInfo 업데이트 + const userBets = bets.filter(b => b.userId === user.id); + + userBets.forEach((bet) => { + const betResult = this.sql.exec( + 'SELECT isWin, reward, betType FROM current_bet WHERE id = ?', + bet.id + ).one(); + + // betInfo 배열에서 해당 배팅 찾기 + const betInfoItem = session.betInfo.find( + b => b.gameId === this.gameId && b.betType === betResult.betType + ); + + if (betInfoItem) { + betInfoItem.isWin = betResult.isWin === 1; + betInfoItem.winMoney = betResult.reward; + } + }); + + // 자본금 업데이트 (DB에서 가져오기) + const updatedUser = this.getUser(user.id); + if (updatedUser) { + session.capital = updatedUser.capital; + } + }); + } // 상태 저장 this.ctx.storage.put('noMoreBet', this.noMoreBet); @@ -472,17 +795,17 @@ export class CounterDurableObject { this.dice2 = null; this.dice3 = null; - // 모든 세션의 배팅 및 결과 클리어 + // current_bet 테이블의 모든 데이터 삭제 (새 게임 시작) + try { + this.sql.exec('DELETE FROM current_bet'); + console.log('Cleared all bets from current_bet table'); + } catch (error) { + console.error('Error clearing current_bet table:', error); + } + + // 모든 세션의 betInfo 클리어 this.sessions.forEach((session) => { - session.oddBet = 0; - session.evenBet = 0; - session.bigBet = 0; - session.smallBet = 0; - session.oddResult = null; - session.evenResult = null; - session.bigResult = null; - session.smallResult = null; - session.lastWinAmount = 0; + session.betInfo = []; }); // 상태 저장 @@ -501,19 +824,71 @@ export class CounterDurableObject { } async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean) { + // 세션에서 userId 조회 + const session = this.sessions.get(ws); + + // 세션 종료 로그 + if (session) { + const userInfo = session.userId + ? `User: ${session.nickname || 'Unknown'} (${session.userId})` + : 'Guest (not authenticated)'; + console.log(`[WebSocket Closed] ${userInfo} | Code: ${code} | Reason: ${_reason || 'No reason'} | Clean: ${_wasClean} | Remaining sessions: ${this.sessions.size - 1}`); + } else { + console.log(`[WebSocket Closed] Unknown session | Code: ${code}`); + } + + if (session && session.userId) { + // userSessions에서 해당 userId의 연결이 현재 ws인 경우만 제거 + if (this.userSessions.get(session.userId) === ws) { + this.userSessions.delete(session.userId); + } + } + // 세션 제거 this.sessions.delete(ws); ws.close(code, 'Durable Object is closing WebSocket'); - // 마지막 접속자가 나갔을 때 게임 루프 중지 - if (this.sessions.size === 0) { - this.stopGameLoop(); - } - - // 남은 클라이언트들에게 업데이트 전송 + // 남은 클라이언트들에게 업데이트 전송 (접속자 수 변경) this.broadcast(); } + /** + * 특정 세션에만 개인 정보 전송 (배팅, 자본금 등) + */ + private sendToSession(ws: WebSocket) { + try { + if (ws.readyState !== WebSocket.OPEN && ws.readyState !== 1) { + return; + } + + const session = this.sessions.get(ws); + if (!session) return; + + // DB에서 최신 capital 조회 + let currentCapital = session.capital; + if (session.userId) { + const user = this.getUser(session.userId); + if (user) { + currentCapital = user.capital; + session.capital = user.capital; + } + } + + const message = JSON.stringify({ + type: 'personalUpdate', + capital: currentCapital, + betInfo: session.betInfo || [], + }); + ws.send(message); + } catch (error) { + console.error('Error sending to session:', error); + } + } + + /** + * 전체 세션에 게임 상태 브로드캐스트 (주사위, 타이머, 접속자 수 등) + * 개인별 정보(capital, betInfo)도 함께 전송 + */ private broadcast() { // WebSocket Hibernation API를 사용할 때는 getWebSockets()로 실제 연결 수를 확인 const connectedWebSockets = this.ctx.getWebSockets(); @@ -522,7 +897,23 @@ export class CounterDurableObject { // @ts-ignore - Cloudflare Workers types 불일치 connectedWebSockets.forEach((ws: WebSocket) => { try { + // WebSocket이 OPEN 상태인지 확인 + if (ws.readyState !== WebSocket.OPEN && ws.readyState !== 1) { + return; + } + const session = this.sessions.get(ws); + if (!session) return; + + // DB에서 최신 capital 조회 (세션 메모리와 DB 동기화) + let currentCapital = session.capital; + if (session.userId) { + const user = this.getUser(session.userId); + if (user) { + currentCapital = user.capital; + session.capital = user.capital; // 세션도 동기화 + } + } const message = JSON.stringify({ noMoreBet: this.noMoreBet, @@ -531,17 +922,14 @@ export class CounterDurableObject { dice3: this.dice3, noMoreBetStartTime: this.noMoreBetStartTime, noMoreBetEndTime: this.noMoreBetEndTime, - online: connectedWebSockets.length, + online: this.sessions.size, + gameId: this.gameId, + // 세션별 사용자 정보 (DB 최신 값) + nickname: session.nickname, + capital: currentCapital, // 세션별 배팅 정보 - capital: session?.capital, - oddResult: session?.oddResult, - evenResult: session?.evenResult, - bigResult: session?.bigResult, - smallResult: session?.smallResult, - lastWinAmount: session?.lastWinAmount, - + betInfo: session.betInfo || [], }); - ws.send(message); } catch (error) { console.error('Error broadcasting to client:', error); diff --git a/src/lib/db-schema.ts b/src/lib/db-schema.ts index d9be09d..c411df7 100644 --- a/src/lib/db-schema.ts +++ b/src/lib/db-schema.ts @@ -4,7 +4,6 @@ export const DB_SCHEMA = ` -- User 테이블: 사용자 정보 저장 CREATE TABLE IF NOT EXISTS user ( id TEXT PRIMARY KEY, - nickname TEXT NOT NULL, email TEXT NOT NULL UNIQUE, joinGameCount INTEGER DEFAULT 0, capital INTEGER DEFAULT 10000 @@ -31,7 +30,6 @@ export const DB_SCHEMA = ` // 타입 정의 export interface UserRecord { id: string; - nickname: string; email: string; joinGameCount: number; capital: number; @@ -42,9 +40,10 @@ export interface CurrentBetRecord { gameId: string; diceNum: number; userId: string; - betType: string; + betType: string; // BetTypeKey를 문자열로 저장 amount: number; isWin: number; // SQLite는 boolean이 없어서 0 또는 1 reward: number; + valid?: number; // 유효 배팅 여부 (0 또는 1) } diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 65a7b11..8db028e 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -19,7 +19,7 @@ export async function verifyPassword(password: string, hash: string): Promise { +export async function createToken(payload: { userId: string; email: string }): Promise { return await new SignJWT({ ...payload }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() @@ -27,10 +27,10 @@ export async function createToken(payload: { userId: number; email: string }): P .sign(JWT_SECRET); } -export async function verifyToken(token: string): Promise<{ userId: number; email: string } | null> { +export async function verifyToken(token: string): Promise<{ userId: string; email: string } | null> { try { const { payload } = await jwtVerify(token, JWT_SECRET); - return payload as { userId: number; email: string }; + return payload as { userId: string; email: string }; } catch { return null; } diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 92675b4..fa98a55 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -17,7 +17,11 @@ export async function createUser( try { const drizzleDb = getDb(db); + // UUID 생성 + const userId = crypto.randomUUID(); + const newUser: NewUser = { + id: userId, email, passwordHash, nickname @@ -56,7 +60,7 @@ export async function getUserByEmail(db: D1Database, email: string): Promise { +export async function getUserById(db: D1Database, id: string): Promise { try { const drizzleDb = getDb(db); diff --git a/src/lib/server/schema.ts b/src/lib/server/schema.ts index cd543b1..1bf2664 100644 --- a/src/lib/server/schema.ts +++ b/src/lib/server/schema.ts @@ -2,7 +2,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; export const users = sqliteTable('users', { - id: integer('id').primaryKey({ autoIncrement: true }), + id: text('id').primaryKey(), email: text('email').notNull().unique(), passwordHash: text('password_hash').notNull(), nickname: text('nickname').notNull(), diff --git a/src/lib/types.ts b/src/lib/types.ts index de3d7f3..c9c1fe1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,18 +1,9 @@ export interface Session { - id: string; - token?: string; + id: string | null; + token: string | null; webSocket: WebSocket; - nickname?: string; - capital?: number; - oddBet: number; - evenBet: number; - bigBet: number; - smallBet: number; - oddResult: 'win' | 'lose' | null; - evenResult: 'win' | 'lose' | null; - bigResult: 'win' | 'lose' | null; - smallResult: 'win' | 'lose' | null; - lastWinAmount: number; + nickname: string | null; + capital: number; betInfo?: BettingInfo[]; // 배팅 정보 (기존 호환성을 위해 optional) } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index b396376..cbe3868 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,8 +1,18 @@ +import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ locals }) => { +export const load: PageServerLoad = async ({ locals, cookies }) => { + // 로그인하지 않은 사용자는 로그인 페이지로 리다이렉트 + if (!locals.user) { + throw redirect(303, '/login?redirectTo=/'); + } + + // JWT 토큰 가져오기 + const authToken = cookies.get('auth_token'); + return { - user: locals.user + user: locals.user, + authToken }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 841a21e..30130e4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,4 +1,7 @@ + +{#if isDuplicateLogin} +
+
+
+
+ + + +
+

세션 종료

+

+ 새로운 장치에서 로그인되어
+ 현재 세션이 종료되었습니다. +

+ +
+
+
+{/if} +
-
+
{#if data.user}
@@ -243,12 +333,24 @@

로그인됨

{data.user.nickname} ({data.user.email})

- +
+
+
+ {online}명 접속 +
+ + 프로필 수정 + + +
{:else}
@@ -259,118 +361,20 @@
{/if} -

- Durable Objects TEST -

- - -
- -
-
-
-
- - {isConnected ? '연결됨' : isConnecting ? '연결 중...' : '연결 안됨'} - -
- {#if !isConnected} - - - - {:else} - - {/if} -
-
- - -
-
- {online} -
-
실시간 접속자
-
- - -
- {#if isConnected && nickname} -
{nickname}
-
{capital.toLocaleString()}원
-
현재 자본금
- {#if lastWinAmount !== 0} -
- {lastWinAmount > 0 ? '+' : ''}{lastWinAmount.toLocaleString()}원 -
- {/if} - {:else} -
-
-
자본금
- {/if} -
-
- - -
-
-
- {noMoreBet ? '🚫 베팅 마감' : '✅ 베팅 가능'} -
- - -
-
- {remainingTime} -
-
- 초 -
-
- -
- {noMoreBet ? '주사위를 굴리는 중... 다음 라운드를 기다려주세요' : '홀/짝, 대/소를 선택하세요!'} -
-
-
- + {#if gameId} +
현재 게임 ID: {gameId}
+ {/if} + {#if dice1 !== null && dice2 !== null && dice3 !== null} -
-

- 🎲 결과 -

- +
-
+
{#each [dice1, dice2, dice3] as dice}
-
-
+
+
{#each getDiceDots(dice) as row} {#each row as dot}
@@ -382,95 +386,51 @@ {/each}
-
+
{dice}
{/each}
- - -
-
- 합계: {dice1 + dice2 + dice3} -
-
- - {(dice1 + dice2 + dice3) % 2 === 1 ? '✓ 홀수' : '홀수'} - - - {(dice1 + dice2 + dice3) % 2 === 0 ? '✓ 짝수' : '짝수'} - -
-
- - {(dice1 + dice2 + dice3) >= 10 ? '✓ 대' : '대'} - - - {(dice1 + dice2 + dice3) <= 9 ? '✓ 소' : '소'} - -
-
- - - {#if oddBet > 0 || evenBet > 0 || bigBet > 0 || smallBet > 0} -
- {#if oddBet > 0} -
-
홀수 배팅
-
{oddBet.toLocaleString()}원
- {#if oddResult === 'win'} -
✓ 승리 +{oddBet.toLocaleString()}원
- {:else if oddResult === 'lose'} -
✗ 패배
- {/if} -
- {/if} - {#if evenBet > 0} -
-
짝수 배팅
-
{evenBet.toLocaleString()}원
- {#if evenResult === 'win'} -
✓ 승리 +{evenBet.toLocaleString()}원
- {:else if evenResult === 'lose'} -
✗ 패배
- {/if} -
- {/if} - {#if bigBet > 0} -
-
대 배팅
-
{bigBet.toLocaleString()}원
- {#if bigResult === 'win'} -
✓ 승리 +{bigBet.toLocaleString()}원
- {:else if bigResult === 'lose'} -
✗ 패배
- {/if} -
- {/if} - {#if smallBet > 0} -
-
소 배팅
-
{smallBet.toLocaleString()}원
- {#if smallResult === 'win'} -
✓ 승리 +{smallBet.toLocaleString()}원
- {:else if smallResult === 'lose'} -
✗ 패배
- {/if} -
- {/if} -
- {/if}
{/if} + +
+
+
+ {noMoreBet ? '🚫 No more bet' : '✅ Place your bet'} +
+ +
+ s +
+
+
+
+
+
+
+ {isConnected ? '연결됨' : '연결 대기'} +
+
+
실시간 접속자
+
+
+
{nickname || '---'}
+
+
현재 자본금
+ {#if betInfo && betInfo.length > 0} +
배팅
+ {/if} +
+
+ + + -
+

배팅 선택 (1000원씩 배팅)

@@ -486,7 +446,7 @@ > 홀수 {#if oddBet > 0} -
{oddBet.toLocaleString()}원
+
{/if} -
@@ -534,6 +494,33 @@ {/if}
+ +
+

나의 베팅 내역

+ {#if betInfo.length === 0} +

아직 배팅 기록이 없습니다.

+ {:else} +
    + {#each betInfo.slice().reverse() as b, i} +
  • +
    + {b.gameId.slice(0,8)}... + {b.betType} +
    +
    +
    + {#if b.isWin !== undefined} +
    + {b.isWin ? `+` : ''}{#if b.isWin}원{:else}-{/if} +
    + {/if} +
    +
  • + {/each} +
+ {/if} +
+
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 75ebf26..e68ed40 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -4,7 +4,7 @@ import { verifyPassword, createToken } from '$lib/server/auth'; import { getUserByEmail } from '$lib/server/db'; export const actions = { - default: async ({ request, platform, cookies }) => { + default: async ({ request, platform, cookies, url }) => { const data = await request.formData(); const email = data.get('email'); const password = data.get('password'); @@ -46,7 +46,9 @@ export const actions = { maxAge: 60 * 60 * 24 * 7 // 7 days }); - throw redirect(303, '/'); + // redirectTo 쿼리 파라미터가 있으면 해당 페이지로, 없으면 메인 페이지로 + const redirectTo = url.searchParams.get('redirectTo') || '/'; + throw redirect(303, redirectTo); } } satisfies Actions; diff --git a/src/routes/profile/+page.server.ts b/src/routes/profile/+page.server.ts new file mode 100644 index 0000000..e3ed244 --- /dev/null +++ b/src/routes/profile/+page.server.ts @@ -0,0 +1,63 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { getUserById } from '$lib/server/db'; + +export const load: PageServerLoad = async ({ locals, platform }) => { + // 로그인하지 않은 사용자는 로그인 페이지로 리다이렉트 + if (!locals.user) { + throw redirect(303, '/login?redirectTo=/profile'); + } + + // D1 데이터베이스에서 사용자 정보 조회 + if (platform?.env?.DB) { + const user = await getUserById(platform.env.DB, locals.user.id); + if (user) { + return { + user: { + id: user.id, + email: user.email, + nickname: user.nickname + } + }; + } + } + + return { + user: locals.user + }; +}; + +export const actions = { + default: async ({ request, platform, locals, cookies }) => { + if (!locals.user) { + throw redirect(303, '/login'); + } + + const data = await request.formData(); + const nickname = data.get('nickname'); + + // 입력값 검증 + if (!nickname || typeof nickname !== 'string' || nickname.length < 2) { + return fail(400, { nickname: '', weak: true }); + } + + if (!platform?.env?.DB) { + return fail(500, { error: 'Database not available' }); + } + + try { + // 닉네임 업데이트 + await platform.env.DB.prepare('UPDATE users SET nickname = ? WHERE id = ?') + .bind(nickname, locals.user.id) + .run(); + + // locals.user 업데이트 + locals.user.nickname = nickname; + + return { success: true, nickname }; + } catch (error) { + console.error('Error updating nickname:', error); + return fail(500, { error: 'Failed to update nickname' }); + } + } +} satisfies Actions; diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte new file mode 100644 index 0000000..68ce2d2 --- /dev/null +++ b/src/routes/profile/+page.svelte @@ -0,0 +1,74 @@ + + + + 프로필 수정 - DD Game + + +
+
+

프로필 수정

+ +
+ {#if form?.success} +
+ 닉네임이 성공적으로 변경되었습니다! +
+ {/if} + + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ + +

이메일은 변경할 수 없습니다.

+
+ +
+ + + {#if form?.weak} +

닉네임은 2자 이상이어야 합니다.

+ {/if} +
+ + +
+ + +
+
diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte index 48295ef..06b65b2 100644 --- a/src/routes/register/+page.svelte +++ b/src/routes/register/+page.svelte @@ -1,7 +1,22 @@ - @@ -34,7 +49,8 @@ id="email" name="email" required - value={form?.email ?? ''} + value={email} + oninput={handleEmailChange} class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-white" placeholder="your@email.com" /> @@ -52,13 +68,14 @@ id="nickname" name="nickname" required - value={form?.nickname ?? ''} + bind:value={nickname} class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-white" placeholder="닉네임을 입력하세요" /> {#if form?.weak && form?.field === 'nickname'}

닉네임은 2자 이상이어야 합니다.

{/if} +

이메일 입력 시 자동으로 설정됩니다.