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

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);
+41 -1
View File
@@ -6,7 +6,7 @@ import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop } from '../src/ledger/ledger.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
import { fnAdminCompletionVoid } from '../src/admin/admin.js';
import { fnAdReward, fnQuestAttend, fnQuestVerifySteps } from '../src/quest/quest.js';
import { fnAdReward, fnQuestAttend, fnQuestProgress, fnQuestVerifySteps } from '../src/quest/quest.js';
import { fnQuestVerifyMusicShake, fnQuestVerifyPlace, placeProofStorage } from '../src/quest/verify.js';
import { fnCampaignFund } from '../src/fandom/fandom.js';
@@ -715,3 +715,43 @@ describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => {
// 여기서 남긴 문서를 그대로 읽는다. 그래서 이 테스트는 상태를 지우지 않는다.
});
});
describe('진행 상태 — 일시 정지 · 이어하기 · 진행완료 (fn-quest-progress · 원장 C-83)', () => {
const ref = () => db().doc(`users/${UID}/questProgress/${STEPS_QUEST}`);
it('진행중 → 진행완료를 남기고, 일일 한도 · 완료 횟수는 건드리지 않는다', async () => {
await call(fnQuestProgress, { questId: STEPS_QUEST, state: 'inProgress', total: 1000 });
expect((await ref().get()).get('session.state')).toBe('inProgress');
await call(fnQuestProgress, {
questId: STEPS_QUEST, state: 'paused', progress: 3, total: 60, positionMs: 3000, hits: [0, 0.1, -1],
});
await call(fnQuestProgress, { questId: STEPS_QUEST, state: 'completed', progress: 1200, total: 1000 });
const snap = await ref().get();
// 세션은 통째로 바뀐다 — 일시 정지의 판정 기록이 남지 않는다.
expect(snap.get('session')).toMatchObject({ state: 'completed', progress: 1200, total: 1000 });
expect(snap.get('session.hits')).toBeUndefined();
expect(snap.get('count')).toBeUndefined();
expect(snap.get('dayKey')).toBeUndefined();
});
it('보상을 받으면 세션이 지워지고, 중단(none)도 세션만 지운다', async () => {
await call(fnQuestProgress, { questId: STEPS_QUEST, state: 'completed', progress: 1200, total: 1000 });
await call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 1200 });
expect((await ref().get()).get('session')).toBeUndefined();
expect((await ref().get()).get('count')).toBe(1);
await call(fnQuestProgress, { questId: STEPS_QUEST, state: 'inProgress' });
await call(fnQuestProgress, { questId: STEPS_QUEST, state: 'none' });
expect((await ref().get()).get('session')).toBeUndefined();
expect((await ref().get()).get('count')).toBe(1);
});
it('진행 단계가 없는 유형 · 없는 퀘스트 · 이상한 값은 거부한다(Q-B-12 A)', async () => {
expect(await codeOf(() => call(fnQuestProgress, { questId: ATTEND_QUEST, state: 'inProgress' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestProgress, { questId: 'no-such-quest', state: 'paused' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestProgress, { questId: STEPS_QUEST, state: 'rewarded' }))).toBe('E-QST-02');
expect(await codeOf(() => call(fnQuestProgress, { questId: STEPS_QUEST, state: 'paused', progress: -1 }))).toBe('E-QST-02');
expect(await codeOf(() => call(fnQuestProgress, { questId: STEPS_QUEST, state: 'paused', hits: [2] }))).toBe('E-QST-02');
expect((await db().doc(`users/${UID}/questProgress/${ATTEND_QUEST}`).get()).exists).toBe(false);
});
});