퀘스트 일시 정지 · 이어하기 · 진행 상태 서버 저장 · 목록 보상받기
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user