Music Shake 첫 박 시각 · 카운트다운 · 옛 일시 정지 판 정리

This commit is contained in:
Sn-Kinos
2026-10-06 12:23:47 +09:00
parent 4e8d989f29
commit 14fef64999
24 changed files with 300 additions and 34 deletions
@@ -178,6 +178,8 @@ export const fnQuestProgress = onCall(async (req) => {
total: count(data, 'total', 1_000_000),
positionMs: count(data, 'positionMs', 86_400_000),
...(hits !== undefined ? { hits } : {}),
// 판을 시작할 때의 첫 박 — 운영값이 바뀌면 앱이 옛 일시 정지를 버리고 새 판으로 시작한다.
...(typeof data.firstBeatMs === 'number' ? { firstBeatMs: count(data, 'firstBeatMs', 86_400_000) } : {}),
...(tally ? { judgement: tally } : {}),
updatedAt: FieldValue.serverTimestamp(),
},
+8 -1
View File
@@ -119,6 +119,13 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
if (typeof questSegments === 'number' && questSegments !== totalSegments) {
fail(ERR.questAbnormal, '곡의 구간 수가 퀘스트 정의와 다릅니다.');
}
// 첫 박 시각(영상 시각 기준 박 위치 `firstBeatMs + i·60/bpm`)도 같은 기준이어야 한다.
const questFirstBeat = quest.get('firstBeatMs');
const firstBeatMs = typeof questFirstBeat === 'number' ? questFirstBeat : 0;
const sentFirstBeat = req.data?.firstBeatMs;
if (sentFirstBeat !== undefined && sentFirstBeat !== firstBeatMs) {
fail(ERR.questAbnormal, '첫 박 시각이 퀘스트 정의와 다릅니다.');
}
const limit = await dailyLimitOf(questId);
const state = await progress(uid, questId, key);
if (state.count >= limit) {
@@ -134,7 +141,7 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
type: 'musicShake',
fandomId: optionalStr(req.data, 'fandomId') ?? '',
value: judgement.perfect,
evidence: { musicShake: { trackId, totalSegments, judgements: judgement } },
evidence: { musicShake: { trackId, totalSegments, firstBeatMs, judgements: judgement } },
questId,
verified: grade !== 'F',
rejectReason: grade === 'F' ? ERR.questIncomplete : null,
@@ -321,10 +321,10 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리
it('Music Shake: 곡은 퀘스트 카탈로그와 대조하고 하루 한도는 곡이 아니라 퀘스트 단위다', async () => {
await db().doc('quests/music-e2e').set({
type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 1,
type: 'musicShake', trackId: 't1', totalSegments: 10, firstBeatMs: 17_000, dailyLimit: 1,
});
const shake = (trackId: string, extra: Record<string, unknown> = {}) => call(fnQuestVerifyMusicShake, {
questId: 'music-e2e', trackId, totalSegments: 10,
questId: 'music-e2e', trackId, totalSegments: 10, firstBeatMs: 17_000,
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 }, ...extra,
});
expect(await codeOf(() => shake('t1', { questId: 'no-such-music' }))).toBe('E-QST-01');
@@ -332,11 +332,15 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리
expect(await codeOf(() => shake('t1', {
totalSegments: 1, judgement: { perfect: 1, good: 0, miss: 0, maxCombo: 1 },
}))).toBe('E-QST-02');
// 첫 박 시각이 퀘스트 정의와 다르면 판정표를 받지 않는다.
expect(await codeOf(() => shake('t1', { firstBeatMs: 0 }))).toBe('E-QST-02');
// 동시 요청도 하루 1회. 곡 ID 를 바꿔도 다시 열리지 않는다.
await Promise.allSettled([shake('t1'), shake('t1')]);
expect(await codeOf(() => shake('t1'))).toBe('E-POP-03');
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
const logs = await db().collection(`users/${UID}/activityLogs`).where('questId', '==', 'music-e2e').get();
expect(logs.docs[0]!.get('evidence.musicShake.firstBeatMs')).toBe(17_000);
});
/** 오늘의 출석으로 기본 보상 원장 행을 만든다 — 광고 증분의 기준 행이다. */