Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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,
|
||||
};
|
||||
|
||||
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,
|
||||
// 구간표는 통째로 덮는다. 섞으면 운영에서 지운 구간이 되살아난다.
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -7,8 +7,42 @@ import { HttpsError } from 'firebase-functions/v2/https';
|
||||
* 카탈로그에 먼저 등재한 뒤 여기에 더한다.
|
||||
*/
|
||||
export const ERR = {
|
||||
popInsufficient: 'E-POP-01',
|
||||
popVerifyFailed: 'E-POP-02',
|
||||
popDuplicate: 'E-POP-03',
|
||||
questIncomplete: 'E-QST-01',
|
||||
questAbnormal: 'E-QST-02',
|
||||
questOutOfRange: 'E-QST-03',
|
||||
// 보상 없는 사용자 생성 Quest 하루 상한(`ugcFreeQuestDailyCap`).
|
||||
ugcFreeDailyCap: 'E-QST-04',
|
||||
adLoadFailed: 'E-AD-01',
|
||||
adLimitExceeded: 'E-AD-02',
|
||||
eclipseClosed: 'E-ECL-01',
|
||||
campaignOverGoal: 'E-CMP-01',
|
||||
campaignPartialFail: 'E-CMP-02',
|
||||
campaignNotApproved: 'E-CMP-03',
|
||||
// 검증 활동(Quest 완료) 누적 수 미달 — 해석 확인 요청 Campaign 개설자격 A안.
|
||||
campaignNotEligible: 'E-CMP-04',
|
||||
qnaSelfAnswer: 'E-QNA-01',
|
||||
qnaBountyExpired: 'E-QNA-02',
|
||||
qnaAlreadyAccepted: 'E-QNA-03',
|
||||
payInvalidReceipt: 'E-PAY-01',
|
||||
payVerifyFailed: 'E-PAY-02',
|
||||
paySubscription: 'E-PAY-03',
|
||||
fandomNameTaken: 'E-FDM-01',
|
||||
nebulaDailyLimit: 'E-FDM-02',
|
||||
nebulaNotReady: 'E-FDM-03',
|
||||
primaryCooldown: 'E-FDM-04',
|
||||
// 성운 창립자는 행성 탄생 전 탈퇴 불가(해석 확인 요청 Q-PN-03 A안).
|
||||
nebulaFounderLeave: 'E-FDM-05',
|
||||
permissionDenied: 'E-PERM-01',
|
||||
authFailed: 'E-AUTH-03',
|
||||
// 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다.
|
||||
trnSelfMark: 'E-TRN-01',
|
||||
trnDailyCap: 'E-TRN-02',
|
||||
trnInvalidTarget: 'E-TRN-03',
|
||||
trnNotEligible: 'E-TRN-04',
|
||||
trnDuplicate: 'E-TRN-05',
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERR)[keyof typeof ERR];
|
||||
@@ -18,14 +52,44 @@ type Status = 'invalid-argument' | 'failed-precondition' | 'already-exists'
|
||||
| 'permission-denied' | 'not-found' | 'resource-exhausted' | 'internal';
|
||||
|
||||
const STATUS: Record<ErrorCode, Status> = {
|
||||
'E-POP-01': 'failed-precondition',
|
||||
'E-POP-02': 'internal',
|
||||
'E-POP-03': 'already-exists',
|
||||
'E-QST-01': 'failed-precondition',
|
||||
'E-QST-02': 'permission-denied',
|
||||
'E-QST-03': 'failed-precondition',
|
||||
'E-QST-04': 'resource-exhausted',
|
||||
'E-AD-01': 'internal',
|
||||
'E-AD-02': 'resource-exhausted',
|
||||
'E-ECL-01': 'failed-precondition',
|
||||
'E-CMP-01': 'failed-precondition',
|
||||
'E-CMP-02': 'internal',
|
||||
'E-CMP-03': 'failed-precondition',
|
||||
'E-CMP-04': 'failed-precondition',
|
||||
'E-QNA-01': 'failed-precondition',
|
||||
'E-QNA-02': 'failed-precondition',
|
||||
'E-QNA-03': 'failed-precondition',
|
||||
'E-PAY-01': 'invalid-argument',
|
||||
'E-PAY-02': 'failed-precondition',
|
||||
'E-PAY-03': 'failed-precondition',
|
||||
'E-FDM-01': 'already-exists',
|
||||
'E-FDM-02': 'resource-exhausted',
|
||||
'E-FDM-03': 'failed-precondition',
|
||||
'E-FDM-04': 'failed-precondition',
|
||||
'E-FDM-05': 'failed-precondition',
|
||||
'E-PERM-01': 'permission-denied',
|
||||
'E-AUTH-03': 'permission-denied',
|
||||
'E-TRN-01': 'permission-denied',
|
||||
'E-TRN-02': 'resource-exhausted',
|
||||
'E-TRN-03': 'not-found',
|
||||
'E-TRN-04': 'permission-denied',
|
||||
'E-TRN-05': 'already-exists',
|
||||
};
|
||||
|
||||
/** 카탈로그 ID 를 그대로 실은 오류. `details.code` 가 계약면이다. */
|
||||
export class CatalogError extends HttpsError {
|
||||
constructor(readonly catalogCode: ErrorCode, message: string) {
|
||||
super(STATUS[catalogCode], message, { code: catalogCode });
|
||||
constructor(readonly catalogCode: ErrorCode, message: string, extra: Record<string, unknown> = {}) {
|
||||
super(STATUS[catalogCode], message, { ...extra, code: catalogCode });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +100,8 @@ export class CatalogError extends HttpsError {
|
||||
* => {...}` 형태로는 호출 이후를 도달 불가로 좁히지 못한다. 그러면 호출 뒤의
|
||||
* 코드가 「값이 없을 수도 있다」로 남아 불필요한 널 검사가 번진다.
|
||||
*/
|
||||
export function fail(code: ErrorCode, message: string): never {
|
||||
throw new CatalogError(code, message);
|
||||
export function fail(code: ErrorCode, message: string, extra?: Record<string, unknown>): never {
|
||||
throw new CatalogError(code, message, extra);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* 원장 문서 ID — 설계서 §3.3 「`txId = SHA-256(idempotencyKey)` 의 앞 20자」.
|
||||
*
|
||||
* 조건문으로 중복을 막지 않는 것이 핵심이다. 조회와 쓰기 사이에 동시 요청이
|
||||
* 끼어들 수 있으므로, 문서 ID 충돌을 Firestore 가 원자적으로 판정하게 한다.
|
||||
*/
|
||||
export const txIdOf = (idempotencyKey: string): string =>
|
||||
createHash('sha256').update(idempotencyKey).digest('hex').slice(0, 20);
|
||||
|
||||
/** lot ID 는 원장과 1:1 로 묶어 대사 때 역추적할 수 있게 한다. */
|
||||
export const lotIdOf = (txId: string): string => `lot_${txId}`;
|
||||
|
||||
/** 단말 해시 — 어뷰징 판정 입력. 원값을 저장하지 않는다. */
|
||||
export const deviceHashOf = (raw: string): string =>
|
||||
createHash('sha256').update(raw).digest('hex').slice(0, 32);
|
||||
|
||||
/**
|
||||
* 브리핑 중복 판정 키 — 같은 기사를 두 번 받아도 문서가 늘지 않게 한다.
|
||||
*
|
||||
* 수집기 재시도는 정상 경로다. 자동 생성 ID 를 쓰면 재시도마다 카드가 늘어나고
|
||||
* 관리자가 같은 기사를 세 번 승인하게 된다. 암호 용도가 아니라 중복 판정이므로
|
||||
* sha1 로 충분하고, 앞 12 자면 하루치 기사 수에서 충돌하지 않는다.
|
||||
*/
|
||||
export const briefingHashOf = (key: string): string =>
|
||||
createHash('sha1').update(key).digest('hex').slice(0, 12);
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* User Level · 칭호 · Passport Tier — 정책 원장 **C-18 · B-65 · B-66** 확정.
|
||||
*
|
||||
* 세 값은 **서로 다른 체계**다(B-66). `userExp → userLevel → 칭호` 가 한 줄이고,
|
||||
* Passport Tier 는 별개 필드로 저장한다. 누적 기여 POP 문턱(3,000/20,000/…)으로
|
||||
* 등급을 매기던 빌드 v0.3.0 의 `tierOf` 는 C-18 로 기각되어 이 파일로 대체했다(R04).
|
||||
*/
|
||||
|
||||
export type Title = 'GLOW' | 'SPARK' | 'BEAM' | 'STARLIGHT' | 'NOVA';
|
||||
export type PassportTier = 'SILVER' | 'GOLD' | 'DIAMOND';
|
||||
|
||||
/**
|
||||
* 레벨 요구량 곡선 — **Lv.L → L+1 요구량 = 243 × L^1.5 (10 단위 반올림)**, 누적은 그 합.
|
||||
*
|
||||
* S01 확정 전 유지 기준(S01 제안서 A안). 새 곡선을 만들지 않고 Admin Figma 표본
|
||||
* 「Lv.18 · User EXP 128,450 · Next Level 14,550 EXP」에 맞춘 식이다 — 누적 Lv.18 = 124,450,
|
||||
* Lv.19 = 143,010 이라 표본에서 다음 레벨까지 14,560(표본 대비 10 차). 확정되면 이 두 상수가 바뀐다.
|
||||
*/
|
||||
export const EXP_CURVE_K = 243;
|
||||
export const EXP_CURVE_POWER = 1.5;
|
||||
|
||||
/** Lv.L 에서 L+1 로 가는 데 필요한 EXP. */
|
||||
export function expToNext(level: number): number {
|
||||
return Math.round(EXP_CURVE_K * Math.pow(level, EXP_CURVE_POWER) / 10) * 10;
|
||||
}
|
||||
|
||||
/** 그 레벨에 **도달하는** 누적 EXP. Lv.1 은 0 이다. */
|
||||
export function expForLevel(level: number): number {
|
||||
let total = 0;
|
||||
for (let l = 1; l < level; l += 1) total += expToNext(l);
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 누적 EXP → 레벨. 상한 없이 100 이후에도 계속 오른다(C-18). */
|
||||
export function levelOf(exp: number): number {
|
||||
let level = 1;
|
||||
let reach = expToNext(1);
|
||||
while (reach <= exp) {
|
||||
level += 1;
|
||||
reach += expToNext(level);
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
/** 레벨 구간 칭호 — 표시일 뿐 권한·보상이 붙지 않는다(C-18). */
|
||||
export function titleOf(level: number): Title {
|
||||
if (level >= 100) return 'NOVA';
|
||||
if (level >= 60) return 'STARLIGHT';
|
||||
if (level >= 30) return 'BEAM';
|
||||
if (level >= 10) return 'SPARK';
|
||||
return 'GLOW';
|
||||
}
|
||||
|
||||
const PASSPORT_ORDER: readonly PassportTier[] = ['SILVER', 'GOLD', 'DIAMOND'];
|
||||
|
||||
/**
|
||||
* Passport Tier — Silver 기본 / Gold Lv.30+ / Diamond Lv.70+(B-65 비고 · v1.6.1 4장 초기 기준, 🟡 운영값).
|
||||
*
|
||||
* **강등하지 않는다** — `current` 보다 낮은 값은 돌려주지 않는다. B-65 의 다른 입력
|
||||
* (활동기간 · Badge · 누적기여)은 조건이 S01 미확정이라 아직 쓰지 않는다.
|
||||
*/
|
||||
export function passportTierOf(level: number, current?: PassportTier | null): PassportTier {
|
||||
const byLevel: PassportTier = level >= 70 ? 'DIAMOND' : level >= 30 ? 'GOLD' : 'SILVER';
|
||||
const kept = current && PASSPORT_ORDER.includes(current) ? current : 'SILVER';
|
||||
return passportRank(byLevel) >= passportRank(kept) ? byLevel : kept;
|
||||
}
|
||||
|
||||
export function passportRank(tier: string | undefined | null): number {
|
||||
return PASSPORT_ORDER.indexOf(tier as PassportTier);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 다른 사용자에게 보이는 이름 — 공개 닉네임만 쓴다(M-7).
|
||||
*
|
||||
* 공급자 실명(`displayName`) · 이메일은 대체값으로 쓰지 않는다. 닉네임이 없거나 이메일
|
||||
* 형태면 「팬」이다 — 빈 이름을 저장하지 않는다. 앱 `publicName` · 규칙 `publicNameOf` 와 같은 계산.
|
||||
*/
|
||||
export const PUBLIC_NAME_FALLBACK = '팬';
|
||||
|
||||
export function publicNameOf(user: { get(field: string): unknown } | null | undefined): string {
|
||||
const raw = user?.get('nickname');
|
||||
const v = typeof raw === 'string' ? raw.trim() : '';
|
||||
return v.length === 0 || v.includes('@') ? PUBLIC_NAME_FALLBACK : v;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 일일 한도의 리셋 단위 — 설계서 `questProgress.dayKey` 「`YYYY-MM-DD`(사용자 시간대)」.
|
||||
*
|
||||
* 서버 UTC 로 끊으면 한국 사용자는 오전 9시에 하루가 바뀐다. 그래서 분 단위
|
||||
* offset 을 받아 사용자 시간대의 날짜를 만든다.
|
||||
*/
|
||||
export function dayKey(at: Date, utcOffsetMinutes = 540): string {
|
||||
const shifted = new Date(at.getTime() + utcOffsetMinutes * 60_000);
|
||||
return shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 시간 조작 탐지 — 단말 시각이 서버 시각에서 너무 벗어나면 증거로 쓰지 않는다. */
|
||||
export function isPlausibleOccurredAt(
|
||||
occurredAt: Date,
|
||||
now: Date,
|
||||
toleranceMinutes = 15,
|
||||
): boolean {
|
||||
const drift = Math.abs(now.getTime() - occurredAt.getTime());
|
||||
return drift <= toleranceMinutes * 60_000;
|
||||
}
|
||||
Reference in New Issue
Block a user