퀘스트 일시 정지 · 이어하기 · 진행 상태 서버 저장 · 목록 보상받기
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1784,3 +1784,24 @@ describe('성운 일 1회 기록 — GALAXY 「성운 생성」 버튼(Figma 540
|
||||
await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-08'), { fandomId: 'f2' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quest 진행 세션 — 일시 정지 · 진행완료(원장 C-83 · fn-quest-progress 전용)', () => {
|
||||
test('본인도 진행 세션을 직접 쓰거나 지울 수 없다 — 함수로만', async () => {
|
||||
await seedDocuments(testEnvironment, {
|
||||
'users/alice/questProgress/shake': { session: { state: 'paused', progress: 43, total: 60 } },
|
||||
});
|
||||
const db = dbFor('alice');
|
||||
await assertFails(setDoc(doc(db, 'users/alice/questProgress/shake'), { session: { state: 'completed' } }, { merge: true }));
|
||||
await assertFails(setDoc(doc(db, 'users/alice/questProgress/new'), { session: { state: 'completed', judgement: { perfect: 60 } } }));
|
||||
await assertFails(deleteDoc(doc(db, 'users/alice/questProgress/shake')));
|
||||
});
|
||||
|
||||
test('본인은 일시 정지 Quest 를 조회할 수 있고(탭 위 캡슐) 다른 사용자는 못 본다', async () => {
|
||||
await seedDocuments(testEnvironment, {
|
||||
'users/alice/questProgress/shake': { session: { state: 'paused', progress: 43, total: 60 } },
|
||||
});
|
||||
const paused = (db) => getDocs(query(collection(db, 'users/alice/questProgress'), where('session.state', '==', 'paused')));
|
||||
await assertSucceeds(paused(dbFor('alice')));
|
||||
await assertFails(paused(dbFor('bob')));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user