M1 기반: 모노레포, 공통 엔진, 공용 프로토콜, 오목 엔진, 서버 골격

- packages/engine: 시드 RNG(sfc32), GameDefinition 타입, 무작위 대국·정보 유출 테스트 도구
- packages/shared: WS 프로토콜 스키마, 닉네임·방 코드 규칙, 게임 카탈로그
- packages/games/omok: 한국식·렌주·자유룰, 재귀 금수 판정, 무르기·무승부·시계 (테스트 27개)
- apps/server: SQLite 스키마, 게스트·디스코드 로그인, 세션, 방 관리(저장·복구·타이머·재접속), WS 게이트웨이, HTTP API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 04:18:29 +09:00
parent f4d63e1544
commit 6ee8da18f1
45 changed files with 4175 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
import type { Database } from 'bun:sqlite';
import { ulid } from 'ulid';
import type { PublicUser } from '@bg/shared';
export interface UserRow {
id: string;
kind: 'guest' | 'member';
nickname: string;
avatar_url: string | null;
use_avatar: number;
settings_json: string;
nick_changed_at: number | null;
nick_change_count: number;
created_at: number;
last_seen_at: number;
deleted_at: number | null;
}
export const NICK_CHANGES_PER_DAY = 10;
export class Users {
constructor(private db: Database) {}
get(id: string): UserRow | null {
return this.db.query<UserRow, [string]>('SELECT * FROM users WHERE id = ? AND deleted_at IS NULL').get(id);
}
createGuest(nickname: string, now = Date.now()): UserRow {
const id = ulid(now);
this.db
.query('INSERT INTO users (id, kind, nickname, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)')
.run(id, 'guest', nickname, now, now);
return this.get(id)!;
}
findByOauth(provider: string, providerUserId: string): UserRow | null {
return this.db
.query<UserRow, [string, string]>(
`SELECT u.* FROM users u JOIN oauth_accounts o ON o.user_id = u.id
WHERE o.provider = ? AND o.provider_user_id = ? AND u.deleted_at IS NULL`,
)
.get(provider, providerUserId);
}
createMember(nickname: string, avatarUrl: string | null, now = Date.now()): UserRow {
const id = ulid(now);
this.db
.query('INSERT INTO users (id, kind, nickname, avatar_url, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, 'member', nickname, avatarUrl, now, now);
return this.get(id)!;
}
linkOauth(userId: string, provider: string, providerUserId: string, username: string | null, now = Date.now()): void {
this.db
.query('INSERT INTO oauth_accounts (provider, provider_user_id, user_id, username, created_at) VALUES (?, ?, ?, ?, ?)')
.run(provider, providerUserId, userId, username, now);
}
promoteToMember(userId: string, avatarUrl: string | null): void {
this.db.query("UPDATE users SET kind = 'member', avatar_url = ? WHERE id = ?").run(avatarUrl, userId);
}
setAvatar(userId: string, avatarUrl: string | null): void {
this.db.query('UPDATE users SET avatar_url = ? WHERE id = ?').run(avatarUrl, userId);
}
/** Moves stats from a guest into a member account and deletes the guest (docs/05 §4). */
mergeGuestInto(guestId: string, memberId: string): void {
this.db.transaction(() => {
const rows = this.db
.query<{ game_id: string; played: number; wins: number; draws: number }, [string]>(
'SELECT game_id, played, wins, draws FROM user_stats WHERE user_id = ?',
)
.all(guestId);
for (const r of rows) {
this.db
.query(
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + excluded.played,
wins = wins + excluded.wins, draws = draws + excluded.draws`,
)
.run(memberId, r.game_id, r.played, r.wins, r.draws);
}
this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(guestId);
this.db.query('UPDATE game_players SET user_id = ? WHERE user_id = ?').run(memberId, guestId);
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(guestId);
this.db.query('DELETE FROM users WHERE id = ?').run(guestId);
})();
}
/** Returns an error message or null. Enforces the daily change limit. */
changeNickname(userId: string, nickname: string, now = Date.now()): string | null {
const u = this.get(userId);
if (!u) return '사용자를 찾을 수 없어요.';
if (u.nickname === nickname) return null;
const sameDay = u.nick_changed_at !== null && now - u.nick_changed_at < 24 * 3600_000;
const count = sameDay ? u.nick_change_count : 0;
if (count >= NICK_CHANGES_PER_DAY) return '닉네임은 하루에 10번까지 바꿀 수 있어요.';
this.db
.query('UPDATE users SET nickname = ?, nick_changed_at = ?, nick_change_count = ? WHERE id = ?')
.run(nickname, sameDay ? u.nick_changed_at : now, count + 1, userId);
return null;
}
updateSettings(userId: string, patch: { useAvatar?: boolean; settings?: Record<string, unknown> }): void {
if (patch.useAvatar !== undefined) {
this.db.query('UPDATE users SET use_avatar = ? WHERE id = ?').run(patch.useAvatar ? 1 : 0, userId);
}
if (patch.settings) {
this.db.query('UPDATE users SET settings_json = ? WHERE id = ?').run(JSON.stringify(patch.settings), userId);
}
}
touch(userId: string, now = Date.now()): void {
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId);
}
softDelete(userId: string, now = Date.now()): void {
this.db.transaction(() => {
this.db
.query("UPDATE users SET deleted_at = ?, nickname = '탈퇴한 사용자', avatar_url = NULL WHERE id = ?")
.run(now, userId);
this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId);
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
})();
}
stats(userId: string): { gameId: string; played: number; wins: number; draws: number }[] {
return this.db
.query<{ gameId: string; played: number; wins: number; draws: number }, [string]>(
'SELECT game_id AS gameId, played, wins, draws FROM user_stats WHERE user_id = ? ORDER BY played DESC',
)
.all(userId);
}
}
export function toPublicUser(u: UserRow): PublicUser {
return {
id: u.id,
nickname: u.nickname,
avatar: u.use_avatar && u.avatar_url ? u.avatar_url : null,
kind: u.kind,
};
}