280 lines
13 KiB
TypeScript
280 lines
13 KiB
TypeScript
import { getFirestore } from 'firebase-admin/firestore';
|
|
|
|
/**
|
|
* 운영값 읽기 — 설계서 §10 「미확정 수치는 하드코딩하지 않고 `config` 컬렉션으로 뺐습니다」.
|
|
*
|
|
* 정책 설계서에서 아직 확정되지 않은 값은 전부 이 경로를 거친다. 코드에 박으면
|
|
* 발주사 회신이 올 때마다 재배포해야 하고, 그 사이 값이 문서와 어긋난다.
|
|
*/
|
|
export interface PolicyConfig {
|
|
/** 출석 1회 보상 활동 POP. */
|
|
attendPop: number;
|
|
/**
|
|
* 사용자 생성 Quest 의 광고 추가 보상 — 원장 **C-02** 고정형 **기여 POP** / 회.
|
|
* 기본 보상액에 비례하지 않고 회사 재원이다(B-28).
|
|
*/
|
|
adRewardPop: number;
|
|
/**
|
|
* 운영(관리자) Quest 의 광고 배수 — 원장 **B-03** 기본 x2. 지정 Quest 는
|
|
* `quests/{id}.adMultiplier`(예: 3)가 덮는다. 지급은 **증분만**(기본 보상 × (배수 − 1))
|
|
* 기여 POP 이다.
|
|
*/
|
|
adMultiplier: number;
|
|
/**
|
|
* POP 묶음 상품(`pop.support.*`) 판매 스위치 — 원장 **B-02** 「POP 자체 직접 판매 없음」
|
|
* 이라 기본 false. 켜지 않으면 `fn-iap-verify` 가 E-PAY-01 로 거부한다.
|
|
*/
|
|
popPackSalesEnabled: boolean;
|
|
/** Focus 구독 일일 Claim 기여 POP — 원장 **B-04** 확정 40. */
|
|
focusDailyPop: number;
|
|
/** 광고 보상 일일 상한 횟수 — E-AD-02 판정 기준. */
|
|
adDailyLimit: number;
|
|
/** 만보기 완주 걸음 수. `stepTiers` 가 없는 문서를 위한 하위 호환값이다. */
|
|
stepsGoal: number;
|
|
/** 만보기 완주 보상 활동 POP. 같은 이유로 남긴다. */
|
|
stepsPop: number;
|
|
/**
|
|
* 걸음 구간표 — 정책 원장 **B-26** 확정값.
|
|
*
|
|
* *1 천/5 천/1 만/1.5 만보 = 일반 POP 5/6/7/8, 전 단계 순차클리어.* 구간을 건너뛰고
|
|
* 윗 구간만 받는 일이 없도록 **아랫 구간부터 차례로** 지급한다. 🟡 보상액은 운영값이라
|
|
* `config/policy` 가 덮는다.
|
|
*/
|
|
stepTiers: StepTier[];
|
|
/** Zap 1회 기여 POP — Figma 「Zap 버튼」이 어드민 설정값이라 명시한 값. */
|
|
zapPop: number;
|
|
/**
|
|
* 연속 ZAP Rate Limit — UX·FX v1.5 §3 「연속 ZAP … Rate Limit 적용」 · PRD-03 「반복 클릭 FPI 제외」.
|
|
* 🟡 **임시값 — 회신 대기**(해석 확인 요청 2026-10-07 영역A Q-A12): 수치는 자료에 없다. 한 호출(앱이 연타를
|
|
* 묶음)의 최대 Zap 횟수와 같은 사용자의 호출 간 최소 간격(ms). 앱은 연타를 450ms 창으로 묶어 보내므로
|
|
* 간격 400ms 는 정상 연타를 막지 않는다(`zap_controller.dart`).
|
|
*/
|
|
zapMaxPerCall: number;
|
|
zapMinIntervalMs: number;
|
|
/** FPI 집계 창 — 설계서 §4.2 최근 7일 Rolling Window. */
|
|
fpiWindowDays: number;
|
|
/**
|
|
* FPI 지표별 가중치.
|
|
*
|
|
* **초기값은 전부 1.0 이다** — 정책 설계서 §P-03 「초기 MVP는 단순 합산」(PRD-03).
|
|
* 가중 평균이 아니므로 합이 1일 필요가 없다. 시즌 고도화 때 Admin 에서 조정한다.
|
|
*/
|
|
fpiWeights: Record<FpiAxis, number>;
|
|
/** 검증 통과 번역 1건 보상 일반 POP — 정책 원장 B-63 확정값 5. 🟡 금액은 운영값. */
|
|
translationPop: number;
|
|
/** 번역 보상 일일 상한(건). B-63 「일일상한은 운영설정」 — 초과분은 등록되지만 보상 0. */
|
|
translationDailyLimit: number;
|
|
/** 확정에 필요한 최소 유효 마킹 수(작성자 제외). 🟡 발주사 회신 전 제안값 1. */
|
|
translationMinValidMarks: number;
|
|
/** 이 시간이 지나도 확정 조건을 못 채우면 보상을 해제하고 닫는다. 🟡 제안값 72. */
|
|
translationMaxPendingHours: number;
|
|
/** INSIDE 번역 마킹을 팬덤 밖 회원에게도 여는가. 🟡 Q-TR-04 권고 B안 true · A안이면 false. */
|
|
translationInsideAnyMember: boolean;
|
|
/** 사용자 생성 Quest 최대 기간(일) — 공개일을 1일째로 센다(C-06). 🟡 운영값. */
|
|
ugcMaxDays: number;
|
|
/** 종료일 선택 시 공개 시각부터 확보해야 하는 최소 시간(C-06). 🟡 운영값. */
|
|
ugcMinHours: number;
|
|
/** 수수료율 · 최소 수수료(B-30 — Pool 의 10%, 최소 10 POP). */
|
|
ugcFeeRate: number;
|
|
ugcFeeMin: number;
|
|
/**
|
|
* 보상 없는 Quest(Pool 0)에도 최소 수수료를 받는가 — 해석 확인 요청 **Q-UQ-02**.
|
|
* 🟡 회신 전 권고안(A안 · 0 POP)으로 false. B안(문언대로 최소 10)은 true.
|
|
*/
|
|
ugcFeeOnNoReward: boolean;
|
|
/** 보상 없는 Quest 의 1인 하루 생성 상한 — 수수료 0 의 도배 방지. 🟡 운영값 3. */
|
|
ugcFreeQuestDailyCap: number;
|
|
/** 생성 자격 — 가입 경과 시간(B-29 · 24h). 검증 활동 1회는 원장에서 본다. */
|
|
ugcMinAccountHours: number;
|
|
/**
|
|
* 생성 자격 — 「검증 활동」(서버가 인정한 Quest 완료 · 보상 무관 · 누적) 최소 횟수(B-29 · 1회).
|
|
* 정의는 해석 확인 요청 Campaign 개설자격 A안. Campaign 쪽 5회는 `config/campaign.minVerifiedQuests`.
|
|
*/
|
|
ugcMinVerifiedQuests: number;
|
|
/** 사람 검토 목표 시간(C-08 · 주말 포함 24h). 지연 안내 기준이며 자동 승인은 없다. */
|
|
ugcReviewHours: number;
|
|
/** 긴급 돌발 타임어택 1회 보상 상한(활동 POP). 🟡 기준자료에 수치가 없어 보수적 출발점 1,000. */
|
|
timeAttackMaxPop: number;
|
|
/**
|
|
* 타임어택 참여 1건의 FPI 축 — 해석 확인 요청 **Q-TA-05**. 🟡 회신 전 권고안(A안) `community`
|
|
* (출석과 같은 축 · C-35). null 이면 B안 — FPI 에 넣지 않고 User EXP 만 준다.
|
|
*/
|
|
timeAttackFpiAxis: FpiAxis | null;
|
|
/**
|
|
* HUD 「LIVE」 = 최근 이 분(分) 안에 앱을 쓴 팬덤 멤버 수 — 해석 확인 요청 **Q-GM-03** A안. 🟡 운영값 5.
|
|
*/
|
|
liveWindowMinutes: number;
|
|
/** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
|
|
referralReferrerPop: number;
|
|
referralReferredPop: number;
|
|
/** 추천인 1명이 하루에 보상 대상으로 받을 수 있는 귀속 수(B-71 어뷰징). 넘으면 `capped`. 🟡 운영값 5. */
|
|
referralDailyCapPerReferrer: number;
|
|
/** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* FPI 산출 지표 — 정책 설계서 §P-03 확정값(답변서 v1.1 Q3).
|
|
*
|
|
* 2주차 설계서가 적었던 `action·content·contribution·community·growth` 를
|
|
* **대체한다.** 두 이름이 공존하면 집계 축과 화면 표기가 갈린다.
|
|
*/
|
|
/** 걸음 구간 하나 — 도달 걸음 수와 그 구간의 보상. */
|
|
export interface StepTier {
|
|
steps: number;
|
|
pop: number;
|
|
}
|
|
|
|
export type FpiAxis = 'bpm' | 'health' | 'referral' | 'community' | 'contribution';
|
|
|
|
export const FPI_AXES: readonly FpiAxis[] = [
|
|
'bpm', 'health', 'referral', 'community', 'contribution',
|
|
];
|
|
|
|
/**
|
|
* 기본값. **정책 확정값이 아니라 config 문서가 없을 때의 안전한 출발점**이다.
|
|
* 운영에서는 `/config/policy` 문서가 항상 이 값을 덮는다.
|
|
*/
|
|
export const DEFAULT_POLICY: PolicyConfig = {
|
|
attendPop: 10,
|
|
adRewardPop: 10,
|
|
adMultiplier: 2,
|
|
popPackSalesEnabled: false,
|
|
focusDailyPop: 40,
|
|
adDailyLimit: 5,
|
|
stepsGoal: 10_000,
|
|
stepsPop: 50,
|
|
stepTiers: [
|
|
{ steps: 1_000, pop: 5 },
|
|
{ steps: 5_000, pop: 6 },
|
|
{ steps: 10_000, pop: 7 },
|
|
{ steps: 15_000, pop: 8 },
|
|
],
|
|
zapPop: 10,
|
|
zapMaxPerCall: 50,
|
|
zapMinIntervalMs: 400,
|
|
fpiWindowDays: 7,
|
|
// 단순 합산이 초기 정책이므로 전부 1.0 이다.
|
|
fpiWeights: {
|
|
bpm: 1, health: 1, referral: 1, community: 1, contribution: 1,
|
|
},
|
|
translationPop: 5,
|
|
// 🟡 운영값. 원장이 수치를 정하지 않아 보수적으로 둔 출발점이다.
|
|
translationDailyLimit: 10,
|
|
translationMinValidMarks: 1,
|
|
translationMaxPendingHours: 72,
|
|
translationInsideAnyMember: true,
|
|
ugcMaxDays: 30,
|
|
ugcMinHours: 1,
|
|
ugcFeeRate: 0.1,
|
|
ugcFeeMin: 10,
|
|
ugcFeeOnNoReward: false,
|
|
ugcFreeQuestDailyCap: 3,
|
|
ugcMinAccountHours: 24,
|
|
ugcMinVerifiedQuests: 1,
|
|
ugcReviewHours: 24,
|
|
timeAttackMaxPop: 1000,
|
|
timeAttackFpiAxis: 'community',
|
|
liveWindowMinutes: 5,
|
|
referralReferrerPop: 100,
|
|
referralReferredPop: 20,
|
|
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> {
|
|
const snap = await getFirestore().doc('config/policy').get();
|
|
if (!snap.exists) return DEFAULT_POLICY;
|
|
const raw = snap.data() ?? {};
|
|
const weights = { ...DEFAULT_POLICY.fpiWeights, ...(raw.fpiWeights ?? {}) };
|
|
return {
|
|
...DEFAULT_POLICY,
|
|
...raw,
|
|
// 가중치는 부분 지정이 흔하므로 축별로 병합한다. 통째로 덮으면 축이 사라진다.
|
|
fpiWeights: weights,
|
|
// 판정 운영값은 키별로 병합한다 — 일부만 바꿔도 나머지 임시값이 남는다.
|
|
musicShake: { ...DEFAULT_POLICY.musicShake, ...(raw.musicShake ?? {}) },
|
|
// 구간표는 통째로 덮는다. 섞으면 운영에서 지운 구간이 되살아난다.
|
|
stepTiers: normalizeTiers(raw.stepTiers) ?? DEFAULT_POLICY.stepTiers,
|
|
} as PolicyConfig;
|
|
}
|
|
|
|
/**
|
|
* 운영 문서의 구간표를 쓸 수 있는 형태로 만든다.
|
|
*
|
|
* **오름차순을 보장한다.** 순서가 뒤섞인 표를 그대로 쓰면 「아랫 구간부터 순차
|
|
* 클리어」가 깨져 윗 구간이 먼저 지급된다(B-26 검증 기준).
|
|
*/
|
|
export function normalizeTiers(raw: unknown): StepTier[] | null {
|
|
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
const tiers: StepTier[] = [];
|
|
for (const row of raw) {
|
|
const steps = (row as StepTier)?.steps;
|
|
const pop = (row as StepTier)?.pop;
|
|
if (typeof steps !== 'number' || typeof pop !== 'number') return null;
|
|
if (!Number.isFinite(steps) || steps <= 0 || pop < 0) return null;
|
|
tiers.push({ steps, pop });
|
|
}
|
|
return tiers.sort((a, b) => a.steps - b.steps);
|
|
}
|
|
|
|
/**
|
|
* 가중치가 쓸 수 있는 값인지 확인한다.
|
|
*
|
|
* 합이 1인지는 보지 않는다 — 단순 합산 정책에서는 합이 5다. 대신 **축이 빠졌거나
|
|
* 음수인지**를 본다. 음수 가중치는 활동할수록 점수가 깎이는 뒤집힌 랭킹을 만든다.
|
|
*/
|
|
export function weightsAreUsable(weights: Record<FpiAxis, number>): boolean {
|
|
return FPI_AXES.every((axis) => {
|
|
const w = weights[axis];
|
|
return typeof w === 'number' && Number.isFinite(w) && w >= 0;
|
|
});
|
|
}
|