닉네임 타이틀·마을 불러오기·동기화 비교 버그 수정

- HUD 타이틀: 닉네임 설정 시 "{닉네임}의 마을 · 회관 LvN"
- 닉네임 모달에 "마을 불러오기" 모드: 기존 마을 이름 입력 →
  1초 디바운스로 존재 확인("마을을 찾았어!") → 서버 세이브 복원
  (/api/village/recover — 토큰 포함 반환, 비밀코드 보호는 추후)
- pull 비교 버그 수정: 오프라인 정산이 로컬 updatedAt을 항상 현재로
  만들어 다른 기기 세이브를 절대 못 받던 문제 → 디스크에서 읽은
  시점(loadedUpdatedAt) 기준으로 비교
- 모달 UX: Enter로 결정(한글 조합 중 제외), IME 간섭 제거(trim 시점 이동),
  모드 전환 시 자동 재체크

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-14 10:44:50 +09:00
co-authored by Claude Fable 5
부모 5dd74babe8
커밋 d5ce9be5f4
5개의 변경된 파일과 148개의 추가작업 그리고 38개의 파일을 삭제
+89 -34
파일 보기
@@ -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<Mode>("new");
const [name, setName] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [submitting, setSubmitting] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | 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<Status, { text: string; cls: string }> = {
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<Mode, Record<Status, { text: string; cls: string }>> = {
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"
/>
<h2 className="font-jua mt-2 text-xl text-[#4a3418]">
그런데… 네 이름이 뭐야?
{mode === "new" ? "그런데… 네 이름이 뭐야?" : "마을을 찾아줄게!"}
</h2>
<p className="mt-1 text-sm text-[#6f5c39]">
촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다
{mode === "new"
? "촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다"
: "예전 마을 이름을 입력하면 그대로 이어서 할 수 있어요"}
</p>
<input
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}
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]"
/>
<p className={`mt-2 h-5 text-xs ${statusLine[status].cls}`}>
{statusLine[status].text}
<p className={`mt-2 h-5 text-xs ${lines[mode][status].cls}`}>
{lines[mode][status].text}
</p>
<button
onClick={confirm}
disabled={status !== "available" || submitting}
onClick={() => void confirm()}
disabled={!canConfirm}
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>
</div>
</div>