> = {
+ 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)