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