diff --git a/src/app/api/village/recover/route.ts b/src/app/api/village/recover/route.ts new file mode 100644 index 0000000..014140e --- /dev/null +++ b/src/app/api/village/recover/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { ensureTable, validNickname } from "@/lib/villages"; + +export const dynamic = "force-dynamic"; + +/** 닉네임으로 마을 복구 (토큰 포함 반환 — 추후 비밀코드 보호 예정) */ +export async function GET(req: Request) { + const nickname = new URL(req.url).searchParams.get("nickname"); + if (!validNickname(nickname)) + return NextResponse.json({ data: null }, { status: 400 }); + try { + await ensureTable(); + const r = await db.query( + "SELECT data, token FROM villages WHERE nickname = $1 AND data IS NOT NULL", + [nickname], + ); + if (!r.rows[0]) return NextResponse.json({ data: null }); + return NextResponse.json({ data: r.rows[0].data, token: r.rows[0].token }); + } catch { + return NextResponse.json({ error: "db" }, { status: 503 }); + } +} diff --git a/src/components/game/Game.tsx b/src/components/game/Game.tsx index 3ef7ba3..06895e5 100644 --- a/src/components/game/Game.tsx +++ b/src/components/game/Game.tsx @@ -265,7 +265,7 @@ export default function Game() { - 🏡 고양이 마을 · 회관 Lv{state.hall.level} + 🏡 {state.nickname ? `${state.nickname}의 마을` : "고양이 마을"} · 회관 Lv{state.hall.level} {/* 자원 pill */} diff --git a/src/components/game/NicknameModal.tsx b/src/components/game/NicknameModal.tsx index 8f7cabe..da4baf9 100644 --- a/src/components/game/NicknameModal.tsx +++ b/src/components/game/NicknameModal.tsx @@ -2,9 +2,16 @@ import { useEffect, useRef, useState } from "react"; import { catSprite } from "@/lib/game/data"; -import { checkNickname, newSyncToken, registerNickname, pushSave } from "@/lib/game/sync"; +import { + checkNickname, + newSyncToken, + registerNickname, + recoverVillage, + pushSave, +} from "@/lib/game/sync"; import { engine } from "@/lib/game/engine"; +type Mode = "new" | "recover"; type Status = | "idle" | "checking" @@ -16,61 +23,89 @@ type Status = const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/; export default function NicknameModal({ onDone }: { onDone: () => void }) { + const [mode, setMode] = useState("new"); const [name, setName] = useState(""); const [status, setStatus] = useState("idle"); const [submitting, setSubmitting] = useState(false); const timer = useRef | null>(null); const latest = useRef(""); + const trimmed = name.trim(); + // 입력이 멈춘 1초 후 자동 중복 체크 useEffect(() => { - latest.current = name; + latest.current = trimmed; if (timer.current) clearTimeout(timer.current); - if (name.length === 0) { + if (trimmed.length === 0) { setStatus("idle"); return; } - if (!NICKNAME_RE.test(name)) { + if (!NICKNAME_RE.test(trimmed)) { setStatus("invalid"); return; } setStatus("checking"); timer.current = setTimeout(async () => { - const result = await checkNickname(name); - if (latest.current !== name) return; // 입력이 바뀌었으면 무시 + const result = await checkNickname(trimmed); + if (latest.current !== trimmed) return; // 입력이 바뀌었으면 무시 if (result === null) setStatus("offline"); else setStatus(result.available ? "available" : "taken"); }, 1000); return () => { if (timer.current) clearTimeout(timer.current); }; - }, [name]); + }, [trimmed, mode]); + + // 모드별 결정 가능 조건: 새 마을=사용 가능, 불러오기=이미 존재 + const canConfirm = + !submitting && + (mode === "new" ? status === "available" : status === "taken"); const confirm = async () => { - if (status !== "available" || submitting) return; + if (!canConfirm) return; setSubmitting(true); - const token = newSyncToken(); - const ok = await registerNickname(name, token); - if (ok === false) { - // 그 사이 누가 선점 - setStatus("taken"); - setSubmitting(false); - return; + if (mode === "new") { + const token = newSyncToken(); + const ok = await registerNickname(trimmed, token); + if (ok === false) { + setStatus("taken"); // 그 사이 누가 선점 + setSubmitting(false); + return; + } + // 서버 불가여도 로컬에 기록 — 다음 동기화 때 재등록 시도 + engine.setNickname(trimmed, token); + void pushSave(); + } else { + const found = await recoverVillage(trimmed); + if (!found) { + setStatus("offline"); + setSubmitting(false); + return; + } + engine.replaceState(found.data); + engine.setNickname(trimmed, found.token); } - // 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]" }, + const lines: Record> = { + new: { + 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]" }, + }, + recover: { + idle: { text: "예전에 쓰던 마을 이름을 알려줘!", cls: "text-[#8a7551]" }, + invalid: { text: "한글·영문·숫자·-·_ 로 2~12자만 가능해!", cls: "text-[#b0472a]" }, + checking: { text: "마을 명부를 뒤적이는 중… 🐾", cls: "text-[#8a7551]" }, + available: { text: "그 이름의 마을은 없는걸? 🙀", cls: "text-[#b0472a]" }, + taken: { text: "마을을 찾았어! 데려올게 ✨", cls: "text-[#5c8a2a]" }, + offline: { text: "서버에 닿지 않아… 잠시 후 다시 시도해줘", cls: "text-[#b0472a]" }, + }, }; return ( @@ -84,30 +119,50 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) { className="h-28 w-28 object-contain drop-shadow-lg" />

- 그런데… 네 이름이 뭐야? + {mode === "new" ? "그런데… 네 이름이 뭐야?" : "마을을 찾아줄게!"}

- 촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다 + {mode === "new" + ? "촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다" + : "예전 마을 이름을 입력하면 그대로 이어서 할 수 있어요"}

setName(e.target.value.trim())} + onChange={(e) => setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) void confirm(); + }} maxLength={12} autoFocus - placeholder="닉네임 입력" + placeholder={mode === "new" ? "닉네임 입력" : "기존 마을 이름"} 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} +

+ {lines[mode][status].text}

+ + diff --git a/src/lib/game/engine.ts b/src/lib/game/engine.ts index d54a059..3ee0067 100644 --- a/src/lib/game/engine.ts +++ b/src/lib/game/engine.ts @@ -137,6 +137,7 @@ function createInitialState(): GameState { let state: GameState | null = null; let offlineMinutes: number | null = null; +let loadedUpdatedAt = 0; // 디스크에서 읽은 시점의 updatedAt (동기화 비교 기준) function passiveSpeedMul(cat: Cat): number { return cat.passives.reduce((mul, key) => { @@ -290,7 +291,10 @@ export const engine = { const raw = localStorage.getItem(SAVE_KEY); if (raw) { const saved = JSON.parse(raw) as GameState; - if (saved.version === 1) state = saved; + if (saved.version === 1) { + state = saved; + loadedUpdatedAt = saved.updatedAt ?? 0; + } } } catch { // 저장 데이터가 깨졌으면 새로 시작 @@ -349,10 +353,16 @@ export const engine = { /** 서버 세이브로 교체 (더 최신일 때) — 마이그레이션·오프라인 정산 재수행 */ replaceState(data: GameState) { state = data; + loadedUpdatedAt = data.updatedAt ?? 0; this.finishLoad(); this.save(); }, + /** 디스크에서 읽은 시점의 updatedAt */ + loadedUpdatedAt(): number { + return loadedUpdatedAt; + }, + /** 닉네임·토큰 설정 */ setNickname(nickname: string, token: string) { if (!state) return; diff --git a/src/lib/game/sync.ts b/src/lib/game/sync.ts index 50f8a4b..095900e 100644 --- a/src/lib/game/sync.ts +++ b/src/lib/game/sync.ts @@ -53,7 +53,7 @@ export async function pushSave(): Promise { } } -/** 서버 세이브를 가져와 로컬보다 최신이면 교체. 교체 여부 반환 */ +/** 서버 세이브를 가져와 로컬(디스크에서 읽은 시점 기준)보다 최신이면 교체 */ export async function pullSave(): Promise { const s = engine.peek(); if (!s?.nickname || !s.syncToken) return false; @@ -63,7 +63,9 @@ export async function pullSave(): Promise { ); if (!res.ok) return false; const { data } = (await res.json()) as { data: GameState | null }; - if (data && data.version === 1 && data.updatedAt > s.updatedAt) { + // 주의: 현재 state.updatedAt은 틱·오프라인 정산으로 항상 "지금"이므로 + // 디스크에서 읽었던 원본 시각과 비교해야 다른 기기의 세이브를 받아올 수 있음 + if (data && data.version === 1 && data.updatedAt > engine.loadedUpdatedAt()) { engine.replaceState(data); return true; } @@ -73,6 +75,26 @@ export async function pullSave(): Promise { return false; } +/** 닉네임으로 기존 마을 복구 */ +export async function recoverVillage( + nickname: string, +): Promise<{ data: GameState; token: string } | null> { + try { + const res = await fetch( + `/api/village/recover?nickname=${encodeURIComponent(nickname)}`, + ); + if (!res.ok) return null; + const body = (await res.json()) as { + data: GameState | null; + token?: string; + }; + if (body.data && body.token) return { data: body.data, token: body.token }; + } catch { + /* ignore */ + } + return null; +} + export function newSyncToken(): string { return ( Math.random().toString(36).slice(2) + Date.now().toString(36)