From 5dd74babe88c3aa824aa56e98c20e97de423d8ad Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 14 Aug 2026 10:36:22 +0900 Subject: [PATCH] =?UTF-8?q?Phase=20D:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20DB=20=EC=84=B8=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - villages 테이블(pg-jskim): nickname PK + 보호 토큰 + JSONB 세이브 - API: /api/village/check(중복)·register(선점)·save(토큰 검증 업서트)·load - 닉네임 모달: 촌장 냥이 인사 직후 게임 차단, 입력 멈춘 1초 후 자동 중복 체크(디바운스), 사용 가능할 때만 결정 버튼 활성화 한글·영문·숫자 2~12자 검증, 서버 불가 시 로컬 시작 후 재시도 - 동기화: 30초마다 자동 push + 종료 시 push, 부팅 시 서버가 더 최신이면 교체(replaceState → 마이그레이션·오프라인 정산 재수행) - 기존 세이브도 닉네임 미설정이면 모달 표시 Co-Authored-By: Claude Fable 5 --- src/app/api/village/check/route.ts | 18 ++++ src/app/api/village/load/route.ts | 17 ++++ src/app/api/village/register/route.ts | 15 ++++ src/app/api/village/save/route.ts | 16 ++++ src/components/game/Game.tsx | 6 ++ src/components/game/NicknameModal.tsx | 116 ++++++++++++++++++++++++++ src/hooks/useGame.ts | 8 ++ src/lib/game/engine.ts | 21 +++++ src/lib/game/sync.ts | 80 ++++++++++++++++++ src/lib/game/types.ts | 2 + src/lib/villages.ts | 71 ++++++++++++++++ 11 files changed, 370 insertions(+) create mode 100644 src/app/api/village/check/route.ts create mode 100644 src/app/api/village/load/route.ts create mode 100644 src/app/api/village/register/route.ts create mode 100644 src/app/api/village/save/route.ts create mode 100644 src/components/game/NicknameModal.tsx create mode 100644 src/lib/game/sync.ts create mode 100644 src/lib/villages.ts diff --git a/src/app/api/village/check/route.ts b/src/app/api/village/check/route.ts new file mode 100644 index 0000000..3509b35 --- /dev/null +++ b/src/app/api/village/check/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { isAvailable, validNickname } from "@/lib/villages"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const name = new URL(req.url).searchParams.get("name"); + if (!validNickname(name)) + return NextResponse.json( + { available: false, reason: "한글·영문·숫자 2~12자만 가능해요" }, + { status: 200 }, + ); + try { + return NextResponse.json({ available: await isAvailable(name) }); + } catch { + return NextResponse.json({ error: "db" }, { status: 503 }); + } +} diff --git a/src/app/api/village/load/route.ts b/src/app/api/village/load/route.ts new file mode 100644 index 0000000..45e9fb5 --- /dev/null +++ b/src/app/api/village/load/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { loadVillage, validNickname } from "@/lib/villages"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const url = new URL(req.url); + const nickname = url.searchParams.get("nickname"); + const token = url.searchParams.get("token"); + if (!validNickname(nickname) || !token) + return NextResponse.json({ data: null }, { status: 400 }); + try { + return NextResponse.json({ data: await loadVillage(nickname, token) }); + } catch { + return NextResponse.json({ error: "db" }, { status: 503 }); + } +} diff --git a/src/app/api/village/register/route.ts b/src/app/api/village/register/route.ts new file mode 100644 index 0000000..10904c6 --- /dev/null +++ b/src/app/api/village/register/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { register, validNickname } from "@/lib/villages"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + const { nickname, token } = await req.json().catch(() => ({})); + if (!validNickname(nickname) || typeof token !== "string" || token.length < 8) + return NextResponse.json({ ok: false }, { status: 400 }); + try { + return NextResponse.json({ ok: await register(nickname, token) }); + } catch { + return NextResponse.json({ error: "db" }, { status: 503 }); + } +} diff --git a/src/app/api/village/save/route.ts b/src/app/api/village/save/route.ts new file mode 100644 index 0000000..77cf2dc --- /dev/null +++ b/src/app/api/village/save/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { saveVillage, validNickname } from "@/lib/villages"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + const { nickname, token, data } = await req.json().catch(() => ({})); + if (!validNickname(nickname) || typeof token !== "string" || !data) + return NextResponse.json({ ok: false }, { status: 400 }); + try { + const ok = await saveVillage(nickname, token, data); + return NextResponse.json({ ok }, { status: ok ? 200 : 403 }); + } catch { + return NextResponse.json({ error: "db" }, { status: 503 }); + } +} diff --git a/src/components/game/Game.tsx b/src/components/game/Game.tsx index a17712a..3ef7ba3 100644 --- a/src/components/game/Game.tsx +++ b/src/components/game/Game.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useGame } from "@/hooks/useGame"; import VillageView, { type PlacingState } from "@/components/game/VillageView"; import DbStatus from "@/components/DbStatus"; +import NicknameModal from "@/components/game/NicknameModal"; import { engine, effectiveRate, @@ -231,6 +232,11 @@ export default function Game() { } /> + {/* ── 닉네임 입력 (인사 후, 미입력 시 게임 차단) ── */} + {!state.nickname && (state.tutorialStep ?? TUTORIAL_DONE) >= 1 && ( + dispatch(() => undefined)} /> + )} + {/* ── 배치 모드 힌트 ── */} {placing && (
diff --git a/src/components/game/NicknameModal.tsx b/src/components/game/NicknameModal.tsx new file mode 100644 index 0000000..8f7cabe --- /dev/null +++ b/src/components/game/NicknameModal.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { catSprite } from "@/lib/game/data"; +import { checkNickname, newSyncToken, registerNickname, pushSave } from "@/lib/game/sync"; +import { engine } from "@/lib/game/engine"; + +type Status = + | "idle" + | "checking" + | "available" + | "taken" + | "invalid" + | "offline"; + +const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/; + +export default function NicknameModal({ onDone }: { onDone: () => void }) { + const [name, setName] = useState(""); + const [status, setStatus] = useState("idle"); + const [submitting, setSubmitting] = useState(false); + const timer = useRef | null>(null); + const latest = useRef(""); + + // 입력이 멈춘 1초 후 자동 중복 체크 + useEffect(() => { + latest.current = name; + if (timer.current) clearTimeout(timer.current); + if (name.length === 0) { + setStatus("idle"); + return; + } + if (!NICKNAME_RE.test(name)) { + setStatus("invalid"); + return; + } + setStatus("checking"); + timer.current = setTimeout(async () => { + const result = await checkNickname(name); + if (latest.current !== name) return; // 입력이 바뀌었으면 무시 + if (result === null) setStatus("offline"); + else setStatus(result.available ? "available" : "taken"); + }, 1000); + return () => { + if (timer.current) clearTimeout(timer.current); + }; + }, [name]); + + const confirm = async () => { + if (status !== "available" || submitting) return; + setSubmitting(true); + const token = newSyncToken(); + const ok = await registerNickname(name, token); + if (ok === false) { + // 그 사이 누가 선점 + setStatus("taken"); + setSubmitting(false); + return; + } + // ok === null(서버 불가)이어도 로컬에 기록 — 다음 동기화 때 재등록 시도 + engine.setNickname(name, token); + void pushSave(); + setSubmitting(false); + onDone(); + }; + + const statusLine: Record = { + idle: { text: "마을 문패에 걸 이름이야. 한글·영문·숫자 2~12자!", cls: "text-[#8a7551]" }, + invalid: { text: "한글·영문·숫자·-·_ 로 2~12자만 가능해!", cls: "text-[#b0472a]" }, + checking: { text: "다른 마을과 겹치는지 확인 중… 🐾", cls: "text-[#8a7551]" }, + available: { text: "좋아, 이 이름은 비어 있어! ✨", cls: "text-[#5c8a2a]" }, + taken: { text: "앗, 이미 다른 마을이 쓰고 있는 이름이야 🙀", cls: "text-[#b0472a]" }, + offline: { text: "서버에 닿지 않아… 잠시 후 다시 시도해줘", cls: "text-[#b0472a]" }, + }; + + return ( +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + 촌장 냥이 +

+ 그런데… 네 이름이 뭐야? +

+

+ 촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다 +

+ + setName(e.target.value.trim())} + maxLength={12} + autoFocus + placeholder="닉네임 입력" + className="font-jua mt-4 w-full rounded-xl border-2 border-[rgba(126,95,48,.35)] bg-white/80 px-4 py-3 text-center text-lg text-[#3f2c14] outline-none focus:border-[#7fb539]" + /> +

+ {statusLine[status].text} +

+ + +
+
+
+ ); +} diff --git a/src/hooks/useGame.ts b/src/hooks/useGame.ts index 8db7c6b..365cd72 100644 --- a/src/hooks/useGame.ts +++ b/src/hooks/useGame.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { engine } from "@/lib/game/engine"; +import { pullSave, pushSave } from "@/lib/game/sync"; import type { GameState } from "@/lib/game/types"; const SYNC_INTERVAL_MS = 300; @@ -18,6 +19,10 @@ export function useGame() { useEffect(() => { engine.init(); setState(engine.snapshot()); + // 서버 세이브가 더 최신이면 교체 (닉네임 있는 경우) + void pullSave().then((replaced) => { + if (replaced) setState(engine.snapshot()); + }); let raf = 0; let last = performance.now(); @@ -36,10 +41,13 @@ export function useGame() { raf = requestAnimationFrame(loop); const saveTimer = setInterval(() => engine.save(), SAVE_INTERVAL_MS); + const syncTimer = setInterval(() => void pushSave(), 30_000); return () => { cancelAnimationFrame(raf); clearInterval(saveTimer); + clearInterval(syncTimer); engine.save(); + void pushSave(); }; }, []); diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts index 91d5871..d54a059 100644 --- a/src/lib/game/engine.ts +++ b/src/lib/game/engine.ts @@ -295,6 +295,12 @@ export const engine = { } catch { // 저장 데이터가 깨졌으면 새로 시작 } + if (!state) state = createInitialState(); + return this.finishLoad(); + }, + + /** 마이그레이션 + 오프라인 정산 (로컬/원격 로드 공통) */ + finishLoad(): GameState { if (!state) state = createInitialState(); // 구버전 세이브 마이그레이션 (필드 보강) state.resources.stone ??= 0; @@ -340,6 +346,21 @@ export const engine = { return state; }, + /** 서버 세이브로 교체 (더 최신일 때) — 마이그레이션·오프라인 정산 재수행 */ + replaceState(data: GameState) { + state = data; + this.finishLoad(); + this.save(); + }, + + /** 닉네임·토큰 설정 */ + setNickname(nickname: string, token: string) { + if (!state) return; + state.nickname = nickname; + state.syncToken = token; + this.save(); + }, + /** 오프라인 정산 리포트 (1회성) */ consumeOfflineReport(): number | null { const r = offlineMinutes; diff --git a/src/lib/game/sync.ts b/src/lib/game/sync.ts new file mode 100644 index 0000000..50f8a4b --- /dev/null +++ b/src/lib/game/sync.ts @@ -0,0 +1,80 @@ +import type { GameState } from "./types"; +import { engine } from "./engine"; + +/** 닉네임 사용 가능 여부 (null = 서버 확인 불가) */ +export async function checkNickname( + name: string, +): Promise<{ available: boolean; reason?: string } | null> { + try { + const res = await fetch( + `/api/village/check?name=${encodeURIComponent(name)}`, + ); + if (!res.ok) return null; + return (await res.json()) as { available: boolean; reason?: string }; + } catch { + return null; + } +} + +/** 닉네임 선점 등록 */ +export async function registerNickname( + nickname: string, + token: string, +): Promise { + try { + const res = await fetch("/api/village/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nickname, token }), + }); + if (!res.ok) return null; + return ((await res.json()) as { ok: boolean }).ok; + } catch { + return null; + } +} + +/** 현재 상태를 서버에 저장 */ +export async function pushSave(): Promise { + const s = engine.peek(); + if (!s?.nickname || !s.syncToken) return; + try { + await fetch("/api/village/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nickname: s.nickname, + token: s.syncToken, + data: s, + }), + }); + } catch { + // 오프라인이면 다음 주기에 재시도 + } +} + +/** 서버 세이브를 가져와 로컬보다 최신이면 교체. 교체 여부 반환 */ +export async function pullSave(): Promise { + const s = engine.peek(); + if (!s?.nickname || !s.syncToken) return false; + try { + const res = await fetch( + `/api/village/load?nickname=${encodeURIComponent(s.nickname)}&token=${encodeURIComponent(s.syncToken)}`, + ); + if (!res.ok) return false; + const { data } = (await res.json()) as { data: GameState | null }; + if (data && data.version === 1 && data.updatedAt > s.updatedAt) { + engine.replaceState(data); + return true; + } + } catch { + // 무시 — 로컬로 계속 + } + return false; +} + +export function newSyncToken(): string { + return ( + Math.random().toString(36).slice(2) + Date.now().toString(36) + ); +} diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts index 66f38ee..9560b3f 100644 --- a/src/lib/game/types.ts +++ b/src/lib/game/types.ts @@ -73,6 +73,8 @@ export interface GameState { boardVersion?: number; // 보드 개편 마이그레이션 마커 tutorialStep?: number; // 튜토리얼 진행 (99 = 완료) coins?: number; // 냥코인 + nickname?: string; // 유저 닉네임 (DB 동기화 키) + syncToken?: string; // 세이브 보호 토큰 happinessUnlocked?: boolean; // 행복도 시스템 해금 (회관 Lv3 + 햇볕 데크) happinessIntroSeen?: boolean; // 촌장 냥이 행복도 안내를 봤는가 decors?: DecoTile[]; // 꾸미기 바닥 타일 diff --git a/src/lib/villages.ts b/src/lib/villages.ts new file mode 100644 index 0000000..2f9764f --- /dev/null +++ b/src/lib/villages.ts @@ -0,0 +1,71 @@ +import { db } from "@/lib/db"; + +let tableReady = false; + +/** villages 테이블 보장 (최초 1회) */ +export async function ensureTable() { + if (tableReady) return; + await db.query(` + CREATE TABLE IF NOT EXISTS villages ( + nickname TEXT PRIMARY KEY, + token TEXT NOT NULL, + data JSONB, + updated_at TIMESTAMPTZ DEFAULT now() + ) + `); + tableReady = true; +} + +const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/; + +export function validNickname(name: unknown): name is string { + return typeof name === "string" && NICKNAME_RE.test(name); +} + +export async function isAvailable(nickname: string): Promise { + await ensureTable(); + const r = await db.query("SELECT 1 FROM villages WHERE nickname = $1", [ + nickname, + ]); + return r.rowCount === 0; +} + +/** 닉네임 선점 등록. 성공 여부 반환 */ +export async function register(nickname: string, token: string): Promise { + await ensureTable(); + const r = await db.query( + `INSERT INTO villages (nickname, token, data) + VALUES ($1, $2, NULL) + ON CONFLICT (nickname) DO NOTHING`, + [nickname, token], + ); + return (r.rowCount ?? 0) > 0; +} + +/** 세이브 저장 (토큰 일치 시에만) */ +export async function saveVillage( + nickname: string, + token: string, + data: unknown, +): Promise { + await ensureTable(); + const r = await db.query( + `UPDATE villages SET data = $3, updated_at = now() + WHERE nickname = $1 AND token = $2`, + [nickname, token, JSON.stringify(data)], + ); + return (r.rowCount ?? 0) > 0; +} + +/** 세이브 불러오기 (토큰 일치 시에만) */ +export async function loadVillage( + nickname: string, + token: string, +): Promise { + await ensureTable(); + const r = await db.query( + "SELECT data FROM villages WHERE nickname = $1 AND token = $2", + [nickname, token], + ); + return r.rows[0]?.data ?? null; +}