Phase D: 닉네임 기반 DB 세이브 동기화
- 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 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -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;
|
||||
|
||||
@@ -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<boolean | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -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[]; // 꾸미기 바닥 타일
|
||||
|
||||
새 이슈에서 참조
사용자 차단