이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user