이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유

This commit is contained in:
Sn-Kinos
2026-10-05 21:39:22 +09:00
parent 65669b969e
commit 17c658b272
30 changed files with 1337 additions and 144 deletions
+46
View File
@@ -105,6 +105,40 @@ export interface PolicyConfig {
referralAttachDays: number;
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
timeAttackMaxMinutes: number;
/** Music Shake 판정 · 성공 · 보상 운영값(원장 B-24). 아래 [MusicShakePolicy] 참고. */
musicShake: MusicShakePolicy;
}
/** Music Shake 판정 등급. 등급 체계 자체가 기준자료에 없다 — 아래 [MusicShakePolicy] 참고. */
export type ShakeGrade = 'S' | 'A' | 'B' | 'C' | 'F';
/**
* Music Shake 운영값 — `config/policy.musicShake`.
*
* **🟡 임시값 — 회신 대기. 근거 없음.** 원장 B-24 가 확정한 것은 「BPM 기반 Shake 판정이 성공 /
* 보상 기준 · 완청 보상 아님 · 보상액은 운영값」뿐이다. 판정 창 · 성공 기준 · 등급(S/A/B/C) ·
* 등급 배수는 PRD-04 · 상세정의 v1.6.1 §20 · 별첨 B 어디에도 수치가 없어 **저희가 임시로 둔
* 값**이며, 해석 확인 요청 「2026-10-07 Music Shake」(Q-MS-04 · Q-MS-05) 회신으로 바꾼다.
* 앱(`ShakeWindows`)도 같은 기본값을 쓴다.
*/
export interface MusicShakePolicy {
/** 이 블록이 회신 전 임시값인가. 회신 반영 시 false 로 내린다. */
provisional: boolean;
/** perfect 판정 창 — 박과의 거리(박 길이 대비). 🟡 임시 0.2. */
perfectWindow: number;
/** good 판정 창(이 밖이면 miss). 🟡 임시 0.5. */
goodWindow: number;
/** 성공(보상) 문턱 — (perfect + good) / 구간 수. 🟡 임시 0.5(예전 코드의 「50%」를 옮긴 것). */
passRate: number;
/** 등급 문턱 — perfect / 구간 수. S 는 miss 0 도 요구. 🟡 임시(예전 코드 값을 옮긴 것). */
gradeCuts: { S: number; A: number; B: number };
/** 등급별 기본 보상 배수. 🟡 임시(예전 코드 값). 등급 보상 차등 자체가 회신 대기다. */
gradeMultiplier: Record<ShakeGrade, number>;
/**
* 퀘스트 `rewardPop` 이 없을 때의 기본 보상(일반 POP). 🟡 Admin Figma 「Quest 유형별 검증」
* (`2376:2189`) 의 BPM / Shake 「1 POP」 예시값 — 확정값이 아니다.
*/
pop: number;
}
/**
@@ -173,6 +207,16 @@ export const DEFAULT_POLICY: PolicyConfig = {
referralDailyCapPerReferrer: 5,
referralAttachDays: 7,
timeAttackMaxMinutes: 1440,
// 🟡 임시값 — 회신 대기 · 근거 없음(위 MusicShakePolicy 설명).
musicShake: {
provisional: true,
perfectWindow: 0.2,
goodWindow: 0.5,
passRate: 0.5,
gradeCuts: { S: 0.95, A: 0.8, B: 0.6 },
gradeMultiplier: { S: 2, A: 1.5, B: 1.2, C: 1, F: 0 },
pop: 1,
},
};
export async function loadPolicy(): Promise<PolicyConfig> {
@@ -185,6 +229,8 @@ export async function loadPolicy(): Promise<PolicyConfig> {
...raw,
// 가중치는 부분 지정이 흔하므로 축별로 병합한다. 통째로 덮으면 축이 사라진다.
fpiWeights: weights,
// 판정 운영값은 키별로 병합한다 — 일부만 바꿔도 나머지 임시값이 남는다.
musicShake: { ...DEFAULT_POLICY.musicShake, ...(raw.musicShake ?? {}) },
// 구간표는 통째로 덮는다. 섞으면 운영에서 지운 구간이 되살아난다.
stepTiers: normalizeTiers(raw.stepTiers) ?? DEFAULT_POLICY.stepTiers,
} as PolicyConfig;
+41 -19
View File
@@ -5,7 +5,7 @@ import { callerUid, num, optionalStr, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { deviceHashOf, txIdOf } from '../core/ids.js';
import { dayKey, isPlausibleOccurredAt } from '../core/time.js';
import { loadPolicy } from '../core/config.js';
import { DEFAULT_POLICY, loadPolicy, type MusicShakePolicy, type ShakeGrade } from '../core/config.js';
import { grantPop, type GrantInput } from '../ledger/ledger.js';
import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js';
import { dailyLimitOf, progress, progressWriter, questOf } from './quest.js';
@@ -47,26 +47,42 @@ export interface ShakeJudgement {
maxCombo: number;
}
export type ShakeGrade = 'S' | 'A' | 'B' | 'C' | 'F';
export type { ShakeGrade };
/**
* 판정표 → 등급. F 면 실패(보상 0 — 원장 B-24). 문턱은 **운영값**(`config/policy.musicShake`)이며
* 🟡 임시값 — 회신 대기다(근거 없음 · 해석 확인 요청 2026-10-07 Q-MS-05).
*/
export function gradeShake(
judgement: ShakeJudgement,
totalSegments: number,
rule: MusicShakePolicy = DEFAULT_POLICY.musicShake,
): ShakeGrade {
const hit = judgement.perfect + judgement.good;
if (totalSegments <= 0 || hit + judgement.miss !== totalSegments) return 'F';
if (hit / totalSegments < rule.passRate) return 'F';
const accuracy = judgement.perfect / totalSegments;
if (judgement.miss === 0 && accuracy >= 0.95) return 'S';
if (accuracy >= 0.8) return 'A';
if (accuracy >= 0.6) return 'B';
if (hit / totalSegments >= 0.5) return 'C';
return 'F';
if (judgement.miss === 0 && accuracy >= rule.gradeCuts.S) return 'S';
if (accuracy >= rule.gradeCuts.A) return 'A';
if (accuracy >= rule.gradeCuts.B) return 'B';
return 'C';
}
/** 등급별 보상 배수. 기본 보상에 곱한다. */
const GRADE_MULTIPLIER: Readonly<Record<ShakeGrade, number>> = {
S: 2, A: 1.5, B: 1.2, C: 1, F: 0,
};
/** YouTube 링크의 영상 ID — 앱 `youtubeVideoId` 와 같은 규칙. 아니면 null. */
export function videoIdOf(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
let u: URL;
try { u = new URL(raw.trim()); } catch { return null; }
const host = u.hostname.toLowerCase();
const seg = u.pathname.split('/').filter(Boolean);
let id: string | null = null;
if (host === 'youtu.be') id = seg[0] ?? null;
else if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
id = u.searchParams.get('v')
?? (seg.length >= 2 && ['shorts', 'embed', 'live'].includes(seg[0]!) ? seg[1]! : null);
}
return id && /^[A-Za-z0-9_-]{11}$/.test(id) ? id : null;
}
/** `fn-quest-verify-music-shake` */
export const fnQuestVerifyMusicShake = onCall(async (req) => {
@@ -79,8 +95,11 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
// 구간 수도 퀘스트에 정의돼 있으면 그 값만 받는다 — 판정표를 줄여 등급을 올리지 못하게.
const questId = str(req.data, 'questId');
const quest = await questOf(uid, questId, ['musicshake', 'music_shake', 'shake']);
const trackIds = Array.isArray(quest.get('trackIds'))
? quest.get('trackIds') as unknown[] : [quest.get('trackId')];
// 곡은 퀘스트의 카탈로그(`trackIds` · `trackId`) 또는 지정 공식 영상(`link.url` · PRD-04 MVP).
const trackIds = [
...(Array.isArray(quest.get('trackIds')) ? quest.get('trackIds') as unknown[] : [quest.get('trackId')]),
videoIdOf((quest.get('link') as { url?: unknown } | undefined)?.url),
].filter((v) => v != null);
if (!trackIds.includes(trackId)) {
fail(ERR.questIncomplete, '이 퀘스트의 곡이 아닙니다.');
}
@@ -105,7 +124,8 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
fail(ERR.popDuplicate, '오늘 이 퀘스트의 보상은 이미 받았습니다.');
}
const grade = gradeShake(judgement, totalSegments);
const policy = await loadPolicy();
const grade = gradeShake(judgement, totalSegments, policy.musicShake);
const db = getFirestore();
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
@@ -126,16 +146,18 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
});
if (grade === 'F') {
// 판정표가 스스로 모순되거나 완주하지 못한 경우다. 증거는 남기고 보상은 없다.
fail(ERR.questIncomplete, '완주하지 못했습니다.');
// 판정표가 스스로 모순되거나 성공 기준에 못 미친 경우다(B-24 — 판정 실패 시 보상 0).
// 증거는 남기고 보상은 없다.
fail(ERR.questIncomplete, '박자 판정이 성공 기준에 미치지 못했습니다.');
}
const policy = await loadPolicy();
// 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
// 옛 공통값으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`).
// `config/policy.musicShake.pop` 으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`).
// 등급 배수는 🟡 임시값 — 회신 대기(Q-MS-05).
const base = quest.get('rewardPop');
const amount = Math.round(
(typeof base === 'number' && base >= 0 ? base : policy.stepsPop) * GRADE_MULTIPLIER[grade],
(typeof base === 'number' && base >= 0 ? base : policy.musicShake.pop)
* (policy.musicShake.gradeMultiplier[grade] ?? 0),
);
const result = await grantPop({
uid,
+18 -1
View File
@@ -2,7 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { distanceMeters, gradeShake } from '../src/quest/verify.js';
import { distanceMeters, gradeShake, videoIdOf } from '../src/quest/verify.js';
import { DEFAULT_POLICY } from '../src/core/config.js';
import { grantPop, refundPop, spendPop } from '../src/ledger/ledger.js';
/**
@@ -43,6 +44,22 @@ describe('Music Shake 판정 (E-02)', () => {
it('구간이 0이면 실격이다', () => {
expect(gradeShake({ perfect: 0, good: 0, miss: 0, maxCombo: 0 }, 0)).toBe('F');
});
it('성공 문턱 · 등급 문턱은 운영값이다 — 🟡 임시값 회신 대기 표시가 붙어 있다', () => {
expect(DEFAULT_POLICY.musicShake.provisional).toBe(true);
const strict = { ...DEFAULT_POLICY.musicShake, passRate: 0.9 };
// 기본 문턱(0.5)이면 C, 운영값을 0.9 로 올리면 실패다.
expect(gradeShake({ perfect: 6, good: 6, miss: 8, maxCombo: 6 }, 20)).toBe('C');
expect(gradeShake({ perfect: 6, good: 6, miss: 8, maxCombo: 6 }, 20, strict)).toBe('F');
});
it('지정 공식 영상 링크에서 영상 ID 를 꺼낸다', () => {
expect(videoIdOf('https://www.youtube.com/watch?v=WMweEpGlu_U')).toBe('WMweEpGlu_U');
expect(videoIdOf('https://youtu.be/WMweEpGlu_U?si=x')).toBe('WMweEpGlu_U');
expect(videoIdOf('https://music.youtube.com/watch?v=WMweEpGlu_U')).toBe('WMweEpGlu_U');
expect(videoIdOf('https://vimeo.com/1')).toBeNull();
expect(videoIdOf(undefined)).toBeNull();
});
});
describe('방문 거리 (E-06)', () => {
@@ -519,6 +519,24 @@ describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Camp
expect(reply.rewarded).toBe(60);
});
it('Music Shake — 곡은 지정 영상 링크로 대조하고 · rewardPop 이 없으면 운영값 musicShake.pop · 판정 미달은 보상 0', async () => {
await db().doc('quests/music-link').set({
type: 'musicShake', totalSegments: 10, dailyLimit: 2,
link: { url: 'https://youtu.be/WMweEpGlu_U', title: 'BTS - Butter' },
});
await db().doc('config/policy').set({ musicShake: { pop: 4 } }, { merge: true });
const shake = (perfect: number, miss: number) => call(fnQuestVerifyMusicShake, {
questId: 'music-link', trackId: 'WMweEpGlu_U', totalSegments: 10,
judgement: { perfect, good: 0, miss, maxCombo: perfect },
});
// 3/10 — 임시 성공 문턱 0.5 미달 → 보상 0, 원장 없음.
expect(await codeOf(() => shake(3, 7))).toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
// 5/10 — 문턱 0.5 충족 · perfect 0.5 < B 문턱 0.6 → C(배수 1) → 4.
const reply = await shake(5, 5);
expect(reply.rewarded).toBe(4);
});
it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => {
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
const shake = () => call(fnQuestVerifyMusicShake, {