닉네임 타이틀·마을 불러오기·동기화 비교 버그 수정
- HUD 타이틀: 닉네임 설정 시 "{닉네임}의 마을 · 회관 LvN"
- 닉네임 모달에 "마을 불러오기" 모드: 기존 마을 이름 입력 →
1초 디바운스로 존재 확인("마을을 찾았어!") → 서버 세이브 복원
(/api/village/recover — 토큰 포함 반환, 비밀코드 보호는 추후)
- pull 비교 버그 수정: 오프라인 정산이 로컬 updatedAt을 항상 현재로
만들어 다른 기기 세이브를 절대 못 받던 문제 → 디스크에서 읽은
시점(loadedUpdatedAt) 기준으로 비교
- 모달 UX: Enter로 결정(한글 조합 중 제외), IME 간섭 제거(trim 시점 이동),
모드 전환 시 자동 재체크
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -265,7 +265,7 @@ export default function Game() {
|
|||||||
<span className="absolute -top-1 left-4 h-4 w-[3px] -rotate-12 bg-linear-to-b from-[#c9b184] to-[#8c7550]" />
|
<span className="absolute -top-1 left-4 h-4 w-[3px] -rotate-12 bg-linear-to-b from-[#c9b184] to-[#8c7550]" />
|
||||||
<span className="absolute -top-1 right-4 h-4 w-[3px] rotate-12 bg-linear-to-b from-[#c9b184] to-[#8c7550]" />
|
<span className="absolute -top-1 right-4 h-4 w-[3px] rotate-12 bg-linear-to-b from-[#c9b184] to-[#8c7550]" />
|
||||||
<span className="font-jua text-[17px] tracking-wide [text-shadow:0_1px_2px_rgba(0,0,0,.45)]">
|
<span className="font-jua text-[17px] tracking-wide [text-shadow:0_1px_2px_rgba(0,0,0,.45)]">
|
||||||
🏡 고양이 마을 · 회관 Lv{state.hall.level}
|
🏡 {state.nickname ? `${state.nickname}의 마을` : "고양이 마을"} · 회관 Lv{state.hall.level}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/* 자원 pill */}
|
{/* 자원 pill */}
|
||||||
|
|||||||
@@ -2,9 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { catSprite } from "@/lib/game/data";
|
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";
|
import { engine } from "@/lib/game/engine";
|
||||||
|
|
||||||
|
type Mode = "new" | "recover";
|
||||||
type Status =
|
type Status =
|
||||||
| "idle"
|
| "idle"
|
||||||
| "checking"
|
| "checking"
|
||||||
@@ -16,61 +23,89 @@ type Status =
|
|||||||
const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/;
|
const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/;
|
||||||
|
|
||||||
export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
||||||
|
const [mode, setMode] = useState<Mode>("new");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [status, setStatus] = useState<Status>("idle");
|
const [status, setStatus] = useState<Status>("idle");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const latest = useRef("");
|
const latest = useRef("");
|
||||||
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
|
||||||
// 입력이 멈춘 1초 후 자동 중복 체크
|
// 입력이 멈춘 1초 후 자동 중복 체크
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latest.current = name;
|
latest.current = trimmed;
|
||||||
if (timer.current) clearTimeout(timer.current);
|
if (timer.current) clearTimeout(timer.current);
|
||||||
if (name.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!NICKNAME_RE.test(name)) {
|
if (!NICKNAME_RE.test(trimmed)) {
|
||||||
setStatus("invalid");
|
setStatus("invalid");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus("checking");
|
setStatus("checking");
|
||||||
timer.current = setTimeout(async () => {
|
timer.current = setTimeout(async () => {
|
||||||
const result = await checkNickname(name);
|
const result = await checkNickname(trimmed);
|
||||||
if (latest.current !== name) return; // 입력이 바뀌었으면 무시
|
if (latest.current !== trimmed) return; // 입력이 바뀌었으면 무시
|
||||||
if (result === null) setStatus("offline");
|
if (result === null) setStatus("offline");
|
||||||
else setStatus(result.available ? "available" : "taken");
|
else setStatus(result.available ? "available" : "taken");
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => {
|
return () => {
|
||||||
if (timer.current) clearTimeout(timer.current);
|
if (timer.current) clearTimeout(timer.current);
|
||||||
};
|
};
|
||||||
}, [name]);
|
}, [trimmed, mode]);
|
||||||
|
|
||||||
|
// 모드별 결정 가능 조건: 새 마을=사용 가능, 불러오기=이미 존재
|
||||||
|
const canConfirm =
|
||||||
|
!submitting &&
|
||||||
|
(mode === "new" ? status === "available" : status === "taken");
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
if (status !== "available" || submitting) return;
|
if (!canConfirm) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
if (mode === "new") {
|
||||||
const token = newSyncToken();
|
const token = newSyncToken();
|
||||||
const ok = await registerNickname(name, token);
|
const ok = await registerNickname(trimmed, token);
|
||||||
if (ok === false) {
|
if (ok === false) {
|
||||||
// 그 사이 누가 선점
|
setStatus("taken"); // 그 사이 누가 선점
|
||||||
setStatus("taken");
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ok === null(서버 불가)이어도 로컬에 기록 — 다음 동기화 때 재등록 시도
|
// 서버 불가여도 로컬에 기록 — 다음 동기화 때 재등록 시도
|
||||||
engine.setNickname(name, token);
|
engine.setNickname(trimmed, token);
|
||||||
void pushSave();
|
void pushSave();
|
||||||
|
} else {
|
||||||
|
const found = await recoverVillage(trimmed);
|
||||||
|
if (!found) {
|
||||||
|
setStatus("offline");
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
engine.replaceState(found.data);
|
||||||
|
engine.setNickname(trimmed, found.token);
|
||||||
|
}
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
onDone();
|
onDone();
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLine: Record<Status, { text: string; cls: string }> = {
|
const lines: Record<Mode, Record<Status, { text: string; cls: string }>> = {
|
||||||
|
new: {
|
||||||
idle: { text: "마을 문패에 걸 이름이야. 한글·영문·숫자 2~12자!", cls: "text-[#8a7551]" },
|
idle: { text: "마을 문패에 걸 이름이야. 한글·영문·숫자 2~12자!", cls: "text-[#8a7551]" },
|
||||||
invalid: { text: "한글·영문·숫자·-·_ 로 2~12자만 가능해!", cls: "text-[#b0472a]" },
|
invalid: { text: "한글·영문·숫자·-·_ 로 2~12자만 가능해!", cls: "text-[#b0472a]" },
|
||||||
checking: { text: "다른 마을과 겹치는지 확인 중… 🐾", cls: "text-[#8a7551]" },
|
checking: { text: "다른 마을과 겹치는지 확인 중… 🐾", cls: "text-[#8a7551]" },
|
||||||
available: { text: "좋아, 이 이름은 비어 있어! ✨", cls: "text-[#5c8a2a]" },
|
available: { text: "좋아, 이 이름은 비어 있어! ✨", cls: "text-[#5c8a2a]" },
|
||||||
taken: { text: "앗, 이미 다른 마을이 쓰고 있는 이름이야 🙀", cls: "text-[#b0472a]" },
|
taken: { text: "앗, 이미 다른 마을이 쓰고 있는 이름이야 🙀", cls: "text-[#b0472a]" },
|
||||||
offline: { 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 (
|
return (
|
||||||
@@ -84,30 +119,50 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
|||||||
className="h-28 w-28 object-contain drop-shadow-lg"
|
className="h-28 w-28 object-contain drop-shadow-lg"
|
||||||
/>
|
/>
|
||||||
<h2 className="font-jua mt-2 text-xl text-[#4a3418]">
|
<h2 className="font-jua mt-2 text-xl text-[#4a3418]">
|
||||||
그런데… 네 이름이 뭐야?
|
{mode === "new" ? "그런데… 네 이름이 뭐야?" : "마을을 찾아줄게!"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm text-[#6f5c39]">
|
<p className="mt-1 text-sm text-[#6f5c39]">
|
||||||
촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다
|
{mode === "new"
|
||||||
|
? "촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다"
|
||||||
|
: "예전 마을 이름을 입력하면 그대로 이어서 할 수 있어요"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value.trim())}
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.nativeEvent.isComposing) void confirm();
|
||||||
|
}}
|
||||||
maxLength={12}
|
maxLength={12}
|
||||||
autoFocus
|
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]"
|
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]"
|
||||||
/>
|
/>
|
||||||
<p className={`mt-2 h-5 text-xs ${statusLine[status].cls}`}>
|
<p className={`mt-2 h-5 text-xs ${lines[mode][status].cls}`}>
|
||||||
{statusLine[status].text}
|
{lines[mode][status].text}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={confirm}
|
onClick={() => void confirm()}
|
||||||
disabled={status !== "available" || submitting}
|
disabled={!canConfirm}
|
||||||
className="nature-btn mt-3 w-full py-2.5 text-base disabled:cursor-not-allowed disabled:opacity-40"
|
className="nature-btn mt-3 w-full py-2.5 text-base disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{submitting ? "문패 만드는 중…" : "이 이름으로 할래! 🐾"}
|
{submitting
|
||||||
|
? mode === "new"
|
||||||
|
? "문패 만드는 중…"
|
||||||
|
: "마을 데려오는 중…"
|
||||||
|
: mode === "new"
|
||||||
|
? "이 이름으로 할래! 🐾"
|
||||||
|
: "이 마을 불러오기 🏡"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMode(mode === "new" ? "recover" : "new")}
|
||||||
|
className="mt-3 text-xs text-[#8a7551] underline underline-offset-2 hover:text-[#5c8a2a]"
|
||||||
|
>
|
||||||
|
{mode === "new"
|
||||||
|
? "이미 마을이 있어요 — 불러오기"
|
||||||
|
: "새 마을을 만들래요"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-1
@@ -137,6 +137,7 @@ function createInitialState(): GameState {
|
|||||||
|
|
||||||
let state: GameState | null = null;
|
let state: GameState | null = null;
|
||||||
let offlineMinutes: number | null = null;
|
let offlineMinutes: number | null = null;
|
||||||
|
let loadedUpdatedAt = 0; // 디스크에서 읽은 시점의 updatedAt (동기화 비교 기준)
|
||||||
|
|
||||||
function passiveSpeedMul(cat: Cat): number {
|
function passiveSpeedMul(cat: Cat): number {
|
||||||
return cat.passives.reduce((mul, key) => {
|
return cat.passives.reduce((mul, key) => {
|
||||||
@@ -290,7 +291,10 @@ export const engine = {
|
|||||||
const raw = localStorage.getItem(SAVE_KEY);
|
const raw = localStorage.getItem(SAVE_KEY);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const saved = JSON.parse(raw) as GameState;
|
const saved = JSON.parse(raw) as GameState;
|
||||||
if (saved.version === 1) state = saved;
|
if (saved.version === 1) {
|
||||||
|
state = saved;
|
||||||
|
loadedUpdatedAt = saved.updatedAt ?? 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 저장 데이터가 깨졌으면 새로 시작
|
// 저장 데이터가 깨졌으면 새로 시작
|
||||||
@@ -349,10 +353,16 @@ export const engine = {
|
|||||||
/** 서버 세이브로 교체 (더 최신일 때) — 마이그레이션·오프라인 정산 재수행 */
|
/** 서버 세이브로 교체 (더 최신일 때) — 마이그레이션·오프라인 정산 재수행 */
|
||||||
replaceState(data: GameState) {
|
replaceState(data: GameState) {
|
||||||
state = data;
|
state = data;
|
||||||
|
loadedUpdatedAt = data.updatedAt ?? 0;
|
||||||
this.finishLoad();
|
this.finishLoad();
|
||||||
this.save();
|
this.save();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 디스크에서 읽은 시점의 updatedAt */
|
||||||
|
loadedUpdatedAt(): number {
|
||||||
|
return loadedUpdatedAt;
|
||||||
|
},
|
||||||
|
|
||||||
/** 닉네임·토큰 설정 */
|
/** 닉네임·토큰 설정 */
|
||||||
setNickname(nickname: string, token: string) {
|
setNickname(nickname: string, token: string) {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|||||||
+24
-2
@@ -53,7 +53,7 @@ export async function pushSave(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 서버 세이브를 가져와 로컬보다 최신이면 교체. 교체 여부 반환 */
|
/** 서버 세이브를 가져와 로컬(디스크에서 읽은 시점 기준)보다 최신이면 교체 */
|
||||||
export async function pullSave(): Promise<boolean> {
|
export async function pullSave(): Promise<boolean> {
|
||||||
const s = engine.peek();
|
const s = engine.peek();
|
||||||
if (!s?.nickname || !s.syncToken) return false;
|
if (!s?.nickname || !s.syncToken) return false;
|
||||||
@@ -63,7 +63,9 @@ export async function pullSave(): Promise<boolean> {
|
|||||||
);
|
);
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
const { data } = (await res.json()) as { data: GameState | null };
|
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);
|
engine.replaceState(data);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -73,6 +75,26 @@ export async function pullSave(): Promise<boolean> {
|
|||||||
return false;
|
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 {
|
export function newSyncToken(): string {
|
||||||
return (
|
return (
|
||||||
Math.random().toString(36).slice(2) + Date.now().toString(36)
|
Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단