퀘스트 일시 정지 · 이어하기 · 진행 상태 서버 저장 · 목록 보상받기

This commit is contained in:
Sn-Kinos
2026-10-06 03:20:46 +09:00
parent ecf5998725
commit abe5f1ae50
28 changed files with 1023 additions and 118 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ export {
fnPopGrant, fnPopSpend, fnPopRefund, fnPopReconcile,
} from './ledger/pop.js';
export {
fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate,
fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate, fnQuestProgress,
} from './quest/quest.js';
export {
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
+63
View File
@@ -119,10 +119,73 @@ export function progressWriter(
rewardedPop,
completedAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
// 보상을 받았으면 진행 세션(C-83 · [fnQuestProgress])은 끝났다 — 다음 회차는 「시작전」부터.
session: FieldValue.delete(),
}, { merge: true });
};
}
/** 진행 세션을 두는 유형 — 실행률을 모으는 걸음 · 화면 안에서 진행하는 Music Shake(Q-B-12 A · C-83). */
const SESSION_TYPES = ['walk', 'steps', 'health', 'musicshake', 'music_shake', 'shake'];
const SESSION_STATES = ['inProgress', 'paused', 'completed'] as const;
/** 0 이상 정수 · 상한 안. 없으면 0. 화면 진행 표시값이라 상한만 둔다. */
function count(data: Record<string, unknown>, key: string, max: number): number {
const v = data[key] ?? 0;
if (typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > max) {
fail(ERR.questAbnormal, `${key} 값이 올바르지 않습니다.`);
}
return v;
}
/**
* `fn-quest-progress` — Quest 진행 상태(진행중 · 일시 정지 · 진행완료)를 진행 문서에 남긴다(원장 C-83).
*
* 화면을 다시 열거나 앱을 다시 켜도 「진행중」 탭 · 「이어하기」 · 목록 「보상받기」가 유지되게 하는
* **표시용 상태**다. 지급 판정은 여기서 하지 않는다 — 보상은 그대로 검증 함수가 증거로 판정한다.
* `state: 'none'` 은 중단(퀘스트 중단 모달 → 시작전)이다. 진행 문서 쓰기는 규칙상 서버 전용이다.
* `dayKey` · `count` 는 건드리지 않는다 — 일일 한도와 완료 알림(`fnInboxQuestDone`)의 원천이다.
*/
export const fnQuestProgress = onCall(async (req) => {
const uid = callerUid(req);
const data = (req.data ?? {}) as Record<string, unknown>;
const questId = str(data, 'questId');
const state = str(data, 'state');
const ref = getFirestore().doc(`users/${uid}/questProgress/${questId}`);
if (state === 'none') {
await ref.set({ session: FieldValue.delete() }, { merge: true });
return { questId, state };
}
if (!(SESSION_STATES as readonly string[]).includes(state)) {
fail(ERR.questAbnormal, '진행 상태가 올바르지 않습니다.');
}
await questOf(uid, questId, SESSION_TYPES);
const hits = data.hits;
if (hits !== undefined && (!Array.isArray(hits) || hits.length > 2000
|| hits.some((h) => typeof h !== 'number' || h < -1 || h > 1))) {
fail(ERR.questAbnormal, '판정 기록이 올바르지 않습니다.');
}
const judgement = data.judgement;
const tally = judgement && typeof judgement === 'object'
? Object.fromEntries(['perfect', 'good', 'miss', 'maxCombo']
.map((k) => [k, count(judgement as Record<string, unknown>, k, 2000)]))
: null;
await ref.set({
questId,
session: {
state,
progress: count(data, 'progress', 1_000_000),
total: count(data, 'total', 1_000_000),
positionMs: count(data, 'positionMs', 86_400_000),
...(hits !== undefined ? { hits } : {}),
...(tally ? { judgement: tally } : {}),
updatedAt: FieldValue.serverTimestamp(),
},
// `session` 을 통째로 바꾼다 — 일시 정지의 판정 기록이 진행완료 뒤에 남지 않게.
}, { mergeFields: ['questId', 'session'] });
return { questId, state };
});
/** `fn-quest-attend` — 출석. 한도는 `quests/{questId}.dailyLimit` 이 정한다. */
export const fnQuestAttend = onCall(async (req) => {
const uid = callerUid(req);