코드 주석을 이 저장소 기준으로 정리
This commit is contained in:
@@ -86,12 +86,12 @@ export async function planetColorKeys(): Promise<string[]> {
|
||||
|
||||
/**
|
||||
* 운영값 기본 — 🟡 `config/policy` 가 덮는다.
|
||||
* - 성운 전환: 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
|
||||
* - 성운 전환: 정책 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
|
||||
* `nebulaConversionDeadlineEnforced` — 7일을 넘긴 성운의 전환을 막을지. B-10 「미달성 시 유지」와
|
||||
* 「7일 이내 판정」이 갈려 발주사 확인 중이며, 회신 전에는 막지 않는다(false).
|
||||
* - 대표 팬덤: 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
|
||||
* - 대표 팬덤: 정책 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
|
||||
* - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지.
|
||||
* 원장 C-101 확정값은 `latest`(가장 최근 가입). `earliest` · `none` 은 확정 전 남겨 둔 운영 스위치.
|
||||
* 정책 원장 C-101 확정값은 `latest`(가장 최근 가입). `earliest` · `none` 은 확정 전 남겨 둔 운영 스위치.
|
||||
* 자동 지정은 C-15 대기에 세지 않는다.
|
||||
*/
|
||||
const FANDOM_DEFAULTS = {
|
||||
@@ -122,7 +122,7 @@ function primaryOf(user: Data): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 팬덤을 [fandomId] 로 바꾸는 필드 — 원장 C-15.
|
||||
* 대표 팬덤을 [fandomId] 로 바꾸는 필드 — 정책 원장 C-15.
|
||||
*
|
||||
* 대표가 없던 계정의 첫 지정은 「변경」이 아니므로 대기를 걸지 않고 시각도 찍지 않는다.
|
||||
* 이미 대표가 있으면 `primaryChangedAt + 대기일` 이 지나야 바뀐다. `patch` 가 null 이면
|
||||
@@ -145,7 +145,7 @@ function primaryPatch(user: Data, fandomId: string, cooldownDays: number, now: n
|
||||
/**
|
||||
* 멤버십 한 건을 트랜잭션 안에서 쓴다. 호출 측이 「아직 멤버가 아님」을 확인한 뒤에만 부른다.
|
||||
*
|
||||
* - `fandoms/{id}/members/{uid}` — 가입 사실 · 가입 순번(`memberNo`, 원장 B-68 #001~ 기록).
|
||||
* - `fandoms/{id}/members/{uid}` — 가입 사실 · 가입 순번(`memberNo`, 정책 원장 B-68 #001~ 기록).
|
||||
* 순번은 탈퇴로 줄지 않는 `memberSeq` 에서 뽑아 재사용하지 않는다.
|
||||
* - `users/{uid}/memberships/{id}` — 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
|
||||
* - `memberCount` 는 같은 트랜잭션에서 올린다 — 전환 조건(B-10)이 이 값을 읽는다.
|
||||
@@ -183,7 +183,7 @@ function writeJoin(tx: Transaction, uid: string, fandomId: string, fandom: Data)
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-nebula-create` — 성운(Stage0) 생성. 원장 B-08 · B-09 · B-10 · C-15.
|
||||
* `fn-nebula-create` — 성운(Stage0) 생성. 정책 원장 B-08 · B-09 · B-10 · C-15.
|
||||
*
|
||||
* 팬덤 문서 자체가 공용 성운/행성이다(팬덤 1 : 행성 1). 만든 사람은 창립 멤버 #001 이다.
|
||||
* 확인 다이얼로그 「선택한 행성을 기본 행성으로 변경할까요?」대로 대표 팬덤을 바꾸되, C-15
|
||||
@@ -237,7 +237,7 @@ export const fnNebulaCreate = onCall(async (req) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-fandom-join` — 행성/성운 가입. 원장 C-15 「복수 팬덤 가입」.
|
||||
* `fn-fandom-join` — 행성/성운 가입. 정책 원장 C-15 「복수 팬덤 가입」.
|
||||
*
|
||||
* 이미 멤버면 아무것도 쓰지 않는다(두 번 눌러도 `memberCount` 는 한 번만 오른다).
|
||||
* 대표 팬덤이 없던 계정은 처음 가입한 팬덤이 대표가 된다 — 그 외에는 대표를 바꾸지 않는다.
|
||||
@@ -276,10 +276,10 @@ export const fnFandomJoin = onCall(async (req) => {
|
||||
* 과거 기여 · Passport 기록(원장 · growthEvents · popTransactions)은 지우지 않는다(C-15).
|
||||
* 순번은 `memberSeq` 를 되돌리지 않으므로 다른 사용자에게 다시 주지 않는다(C-100 · B-68).
|
||||
*
|
||||
* - 성운(Stage0) 창립자도 탄생 전 떠날 수 있다(원장 C-100). 탄생 권한(`founderUid`)은 남은 멤버
|
||||
* - 성운(Stage0) 창립자도 탄생 전 떠날 수 있다(정책 원장 C-100). 탄생 권한(`founderUid`)은 남은 멤버
|
||||
* 가운데 `joinedAt` 이 가장 이른 사용자에게 넘기고 알림을 남긴다. 남은 멤버가 없으면 비워 두고
|
||||
* 다음 가입자가 이어받는다(원장 S20 확인 전 유지 기준). `ownerUid`(행성 문서) 등 최초 생성자 기록은 그대로다.
|
||||
* - 대표 팬덤을 떠나면 남은 팬덤 중 가장 최근 가입한 팬덤을 대표로 둔다(원장 C-101). 자동 지정이라
|
||||
* - 대표 팬덤을 떠나면 남은 팬덤 중 가장 최근 가입한 팬덤을 대표로 둔다(정책 원장 C-101). 자동 지정이라
|
||||
* `primaryChangedAt` 을 찍지 않는다 — 사용자의 다음 직접 변경을 30일 대기로 막지 않는다.
|
||||
*/
|
||||
export const fnFandomLeave = onCall(async (req) => {
|
||||
@@ -343,7 +343,7 @@ export const fnFandomLeave = onCall(async (req) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-primary-fandom-set` — 대표 팬덤 변경. 원장 C-15 · 명세 §2.56 「기본 행성 변경」.
|
||||
* `fn-primary-fandom-set` — 대표 팬덤 변경. 정책 원장 C-15 · 명세 §2.56 「기본 행성 변경」.
|
||||
*
|
||||
* 가입한 팬덤만 대표가 될 수 있다. 변경은 무료이고, 재변경은 `primaryChangedAt + 30일`
|
||||
* (운영값 `primaryCooldownDays`) 이 지나야 한다. 과거 기여 · Passport 는 건드리지 않는다.
|
||||
@@ -369,11 +369,11 @@ export const fnPrimaryFandomSet = onCall(async (req) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-planet-create` — 성운(Stage0) → 정식 행성(Stage1) 탄생. 원장 B-09 · B-10 · C-50.
|
||||
* `fn-planet-create` — 성운(Stage0) → 정식 행성(Stage1) 탄생. 정책 원장 B-09 · B-10 · C-50.
|
||||
*
|
||||
* 형태(`planetType`)와 컬러(`colorKey`) 두 가지만 이때 한 번 고른다. 행성 문서 ID 는
|
||||
* 팬덤 안에서 고정(`planet`)이라 팬덤당 1건이 문서 ID 충돌로 원자적으로 지켜진다
|
||||
* (Firestore 설계서 §2.4 정정). 렌더링이 팬덤 문서 하나로 끝나도록 두 값을 팬덤 문서에도
|
||||
* (Firestore 설계서 §2.4). 렌더링이 팬덤 문서 하나로 끝나도록 두 값을 팬덤 문서에도
|
||||
* 싣는다. 생성 후 두 값을 바꾸는 경로는 없다(보안 규칙이 클라이언트 쓰기를 전부 닫음).
|
||||
*
|
||||
* 전환 조건(인원 · POP)은 **트랜잭션 안에서 읽은** 팬덤 문서로 판정한다 — 가입 · 탈퇴와
|
||||
@@ -505,7 +505,7 @@ export async function hasLocationConsent(uid: string): Promise<boolean> {
|
||||
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'age'] as const;
|
||||
|
||||
/**
|
||||
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 원장 C-134).
|
||||
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 정책 원장 C-134).
|
||||
*
|
||||
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
|
||||
* 찍는다. 앱은 이 값이 없으면 로그인 직후 온보딩(약관부터)으로 보낸다 — 「로그인」 버튼으로 처음
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function inviteCodeOf(uid: string): Promise<string> {
|
||||
/** `fn-invite-code-get` — 내 초대 코드(없으면 만들어서). 공유 링크 `?ref=` 에 붙는다. */
|
||||
export const fnInviteCodeGet = onCall(async (req) => ({ code: await inviteCodeOf(callerUid(req)) }));
|
||||
|
||||
/** 추천 보상 보류 시간 — 원장 **B-71** 「기록 → 보상 pending/24h」. 지난 뒤 정산 작업이 확정한다. */
|
||||
/** 추천 보상 보류 시간 — 정책 원장 **B-71** 「기록 → 보상 pending/24h」. 지난 뒤 정산 작업이 확정한다. */
|
||||
export const REFERRAL_HOLD_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
@@ -109,7 +109,7 @@ export const fnReferralAttribute = onCall(async (req) => {
|
||||
// 자기 추천. 계정 하나로 POP 을 만들어 내는 가장 쉬운 길이다.
|
||||
fail(ERR.questAbnormal, '본인 추천 코드는 쓸 수 없습니다.');
|
||||
}
|
||||
// 추천인 1명당 하루 보상 상한은 두지 않는다 — 10/3 통합회신 7항 「삭제」(원장 C-120).
|
||||
// 추천인 1명당 하루 보상 상한은 두지 않는다 — 10/3 통합회신 7항 「삭제」(정책 원장 C-120).
|
||||
// 자기 · 상호 · 중복 · 이상 패턴은 아래 판정과 Risk Engine 이 맡는다.
|
||||
const { referralAttachDays } = await loadPolicy();
|
||||
|
||||
@@ -244,7 +244,7 @@ export async function settleReferral(uid: string): Promise<'paid' | 'released' |
|
||||
uid: referrerUid, fandomId: fandomOfUser(referrer), axis: 'referral', units: 1,
|
||||
activity: 'referral', sourceId: referralFpiSource(fresh), occurredAt: new Date(),
|
||||
refPath: `referrals/${fresh.get('attributionId')}`,
|
||||
// 추천인은 이 활동을 직접 하지 않았다 — EXP · DAU 에서 뺀다(리뷰 M5). FPI REFERRAL 축만.
|
||||
// 추천인은 이 활동을 직접 하지 않았다 — EXP · DAU 에서 뺀다. FPI REFERRAL 축만.
|
||||
grantsExp: false,
|
||||
}, referrer);
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export function parseTranslationInput(data: unknown): TranslationInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록 시점 판정 — 원장 B-63 「중복·도배 제외 · 일일상한 초과분 미지급」.
|
||||
* 등록 시점 판정 — 정책 원장 B-63 「중복·도배 제외 · 일일상한 초과분 미지급」.
|
||||
* 상한의 [TRANSLATION_HARD_LIMIT_FACTOR] 배를 넘으면 등록 자체를 거부(E-TRN-02),
|
||||
* 상한까지는 보상, 그 사이는 등록만 받고 보상 0.
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ import { voidCompletions } from '../ledger/fpi.js';
|
||||
*/
|
||||
|
||||
/**
|
||||
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**(리뷰 H3).
|
||||
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**.
|
||||
* 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다.
|
||||
*/
|
||||
const FORBIDDEN = [
|
||||
|
||||
@@ -10,7 +10,7 @@ import { verifiedQuestCountOf } from '../ledger/fpi.js';
|
||||
import { publicNameOf } from '../core/names.js';
|
||||
|
||||
/**
|
||||
* `fn-campaign-create` — 사용자 캠페인 개설(원장 B-56 자격 · B-57 등록비).
|
||||
* `fn-campaign-create` — 사용자 캠페인 개설(정책 원장 B-56 자격 · B-57 등록비).
|
||||
*
|
||||
* 예전에는 앱이 `campaigns` 에 `draft` 로 직접 썼는데 개설자는 status 를 바꿀 수 없어
|
||||
* 심사(`review`)로 넘어갈 길이 없었다. 개설 · 등록비 차감 · `review` 진입을
|
||||
|
||||
@@ -25,7 +25,7 @@ const REFUND_DEADLINE_HOURS = 72;
|
||||
|
||||
/**
|
||||
* 자동 환불 패스 상한. 실패가 남은 패스가 이만큼 쌓이면 Admin Exception Queue 로 넘기고
|
||||
* 자동 재시도를 멈춘다(원장 B-36 「부분 실패는 자동 재시도 후 Admin Exception Queue」).
|
||||
* 자동 재시도를 멈춘다(정책 원장 B-36 「부분 실패는 자동 재시도 후 Admin Exception Queue」).
|
||||
*/
|
||||
export const MAX_AUTO_REFUND_ATTEMPTS = 3;
|
||||
|
||||
@@ -369,7 +369,7 @@ export const fnCampaignRefundBatch = onCall({ timeoutSeconds: TIMEOUT_SECONDS },
|
||||
return refundCampaign(str(req.data, 'campaignId'), { manual: true });
|
||||
});
|
||||
|
||||
/** 결과보고 사진·영수증 상한 — 원장 B-44 「이미지 5장 · 10MB」. 용량·형식은 Storage 규칙이 본다. */
|
||||
/** 결과보고 사진·영수증 상한 — 정책 원장 B-44 「이미지 5장 · 10MB」. 용량·형식은 Storage 규칙이 본다. */
|
||||
export const REPORT_MAX_PHOTOS = 5;
|
||||
const REPORT_MAX_CHARS = 5000;
|
||||
const BUDGET_MAX_LINES = 50;
|
||||
@@ -379,7 +379,7 @@ const CAMPAIGN_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
export interface BudgetLine { label: string; amount: number }
|
||||
|
||||
/**
|
||||
* `fn-campaign-report-submit` — 성공 캠페인 결과보고(원장 B-35 · FR-ADM-05 · FR-CMP-06).
|
||||
* `fn-campaign-report-submit` — 성공 캠페인 결과보고(정책 원장 B-35 · FR-ADM-05 · FR-CMP-06).
|
||||
*
|
||||
* **운영(master·ops)만 올린다.** B-35 는 funded 이후 집행·증빙·결과보고를 Admin 수동 운영으로
|
||||
* 두었다. 집행 사진 · 영수증 · 예산 사용 내역을 함께 받아 보고와 상태 `report` 를 한
|
||||
|
||||
@@ -10,22 +10,22 @@ export interface PolicyConfig {
|
||||
/** 출석 1회 보상 활동 POP. */
|
||||
attendPop: number;
|
||||
/**
|
||||
* 사용자 생성 Quest 의 광고 추가 보상 — 원장 **C-02** 고정형 **기여 POP** / 회.
|
||||
* 사용자 생성 Quest 의 광고 추가 보상 — 정책 원장 **C-02** 고정형 **기여 POP** / 회.
|
||||
* 기본 보상액에 비례하지 않고 회사 재원이다(B-28).
|
||||
*/
|
||||
adRewardPop: number;
|
||||
/**
|
||||
* 운영(관리자) Quest 의 광고 배수 — 원장 **B-03** 기본 x2. 지정 Quest 는
|
||||
* 운영(관리자) Quest 의 광고 배수 — 정책 원장 **B-03** 기본 x2. 지정 Quest 는
|
||||
* `quests/{id}.adMultiplier`(예: 3)가 덮는다. 지급은 **증분만**(기본 보상 × (배수 − 1))
|
||||
* 기여 POP 이다.
|
||||
*/
|
||||
adMultiplier: number;
|
||||
/**
|
||||
* POP 묶음 상품(`pop.support.*`) 판매 스위치 — 원장 **B-02** 「POP 자체 직접 판매 없음」
|
||||
* POP 묶음 상품(`pop.support.*`) 판매 스위치 — 정책 원장 **B-02** 「POP 자체 직접 판매 없음」
|
||||
* 이라 기본 false. 켜지 않으면 `fn-iap-verify` 가 E-PAY-01 로 거부한다.
|
||||
*/
|
||||
popPackSalesEnabled: boolean;
|
||||
/** Focus 구독 일일 Claim 기여 POP — 원장 **B-04** 확정 40. */
|
||||
/** Focus 구독 일일 Claim 기여 POP — 정책 원장 **B-04** 확정 40. */
|
||||
focusDailyPop: number;
|
||||
/** 광고 보상 일일 상한 횟수 — E-AD-02 판정 기준. */
|
||||
adDailyLimit: number;
|
||||
@@ -111,14 +111,14 @@ export interface PolicyConfig {
|
||||
* HUD 「LIVE」 = 최근 이 분(分) 안에 앱을 쓴 팬덤 멤버 수 — 해석 확인 요청 **Q-GM-03** A안. 🟡 운영값 5.
|
||||
*/
|
||||
liveWindowMinutes: number;
|
||||
/** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
|
||||
/** 추천 보상 — 정책 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
|
||||
referralReferrerPop: number;
|
||||
referralReferredPop: number;
|
||||
/** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */
|
||||
referralAttachDays: number;
|
||||
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
|
||||
timeAttackMaxMinutes: number;
|
||||
/** Music Shake 판정 · 성공 · 보상 운영값(원장 B-24). 아래 [MusicShakePolicy] 참고. */
|
||||
/** Music Shake 판정 · 성공 · 보상 운영값(정책 원장 B-24). 아래 [MusicShakePolicy] 참고. */
|
||||
musicShake: MusicShakePolicy;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export type ShakeGrade = 'S' | 'A' | 'B' | 'C' | 'F';
|
||||
/**
|
||||
* Music Shake 운영값 — `config/policy.musicShake`.
|
||||
*
|
||||
* **🟡 임시값 — 회신 대기. 근거 없음.** 원장 B-24 가 확정한 것은 「BPM 기반 Shake 판정이 성공 /
|
||||
* **🟡 임시값 — 회신 대기. 근거 없음.** 정책 원장 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) 회신으로 바꾼다.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { dayKey } from '../core/time.js';
|
||||
|
||||
/**
|
||||
* 2026-09-16 EC02·EC05: 누락된 집계를 0으로 채우지 않는다.
|
||||
* 원장 C-26 `target = max(1, ceil(avgDau × rate))` · C-27 생성 후 완료 집계일 ≤ [windowDays] 일 평균 ·
|
||||
* 정책 원장 C-26 `target = max(1, ceil(avgDau × rate))` · C-27 생성 후 완료 집계일 ≤ [windowDays] 일 평균 ·
|
||||
* C-21 집계 기간은 운영값(기본 7일).
|
||||
*/
|
||||
export async function eclipseTarget(
|
||||
|
||||
@@ -102,7 +102,7 @@ const FINAL_STAGE = 6;
|
||||
export interface Promotion { fandomId: string; fromStage: number; toStage: number }
|
||||
|
||||
/**
|
||||
* 정식 행성 Level · Stage 판정 — 수치표 v1.4(`planet_level.ts`) · 원장 **B-11 · B-53 · B-54**.
|
||||
* 정식 행성 Level · Stage 판정 — 수치표 v1.4(`planet_level.ts`) · 정책 원장 **B-11 · B-53 · B-54**.
|
||||
*
|
||||
* - 레벨업 POP 진행도(`levelPop`) = 생성 기준점 이후 Growth POP 증가분 × ZAP 참여도 가중치(`zapWeight`,
|
||||
* 일일 마감이 정함). 가중치는 진행도에만 쓰고 `growthPop`(원장 누계)은 그대로 둔다.
|
||||
@@ -156,7 +156,7 @@ export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]>
|
||||
if (!result) continue;
|
||||
const { name, ...promotion } = result;
|
||||
promoted.push(promotion);
|
||||
// 원장 C-78 ③ 성취 · C-77 「Evolution」 메시지. 발송 실패는 승급을 되돌리지 않는다.
|
||||
// 정책 원장 C-78 ③ 성취 · C-77 「Evolution」 메시지. 발송 실패는 승급을 되돌리지 않는다.
|
||||
await sendFandomPush({
|
||||
fandomId: ref.id,
|
||||
category: 'fandomEvent',
|
||||
|
||||
@@ -156,7 +156,7 @@ export async function defendEclipse(input: {
|
||||
const room = Math.max(0, settings.perUserMaxDefense - myTotal);
|
||||
const accepted = Math.min(shakes, settings.perCallMaxShakes, room);
|
||||
const clamped = shakes - accepted;
|
||||
// 원장 C-25: 서버가 인정한 인정 횟수(기본 60회, 운영값)에 처음 도달할 때 회차당 한 명만 센다.
|
||||
// 정책 원장 C-25: 서버가 인정한 인정 횟수(기본 60회, 운영값)에 처음 도달할 때 회차당 한 명만 센다.
|
||||
// 회차 문서에 고정한 값을 쓴다 — 진행 중 운영값 변경이 같은 회차의 기준을 바꾸지 않는다.
|
||||
const need = (eclipse.get('qualifyShakes') as number | undefined) ?? settings.qualifyShakes;
|
||||
const qualified = myTotal < need && myTotal + accepted >= need;
|
||||
@@ -204,7 +204,7 @@ export async function defendEclipse(input: {
|
||||
});
|
||||
|
||||
if (result.accepted === 0) return result;
|
||||
// **성공 판정을 1 분 집계에만 맡기지 않는다**(검수 D2). 목표에 닿은 순간 세워야
|
||||
// **성공 판정을 1 분 집계에만 맡기지 않는다**. 목표에 닿은 순간 세워야
|
||||
// 그 뒤의 참여가 헛돌지 않고, 화면이 성공을 곧 본다.
|
||||
await markDefendedIfReached(eclipseRef);
|
||||
return result;
|
||||
@@ -300,22 +300,22 @@ export interface EclipseSettings {
|
||||
/** 이 팬덤에 이클립스를 발동하는가. Admin 「활성화」. */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* 공통 발생 일정 — 원장 **C-24** · 설계서 v1.2 `config/eclipse.schedule[]`.
|
||||
* 공통 발생 일정 — 정책 원장 **C-24** · 설계서 v1.2 `config/eclipse.schedule[]`.
|
||||
* 배열 길이가 곧 하루 회차 수다(**하루 2회는 고정 제한이 아니다**). 팬덤 문서의
|
||||
* `eclipseSchedule[]` 이 있으면 그것이 우선한다.
|
||||
*/
|
||||
schedule: EclipseSlot[];
|
||||
/** 1회 발동당 FPI 침식률(%). Admin 「FPI 감소 수치」 · 수치표 v1.4 「침식 0.25% FPI」. */
|
||||
erosionPercent: number;
|
||||
/** 사전 알림 시간(초). 원장 C-22 「발생 3분 전 푸시」 = 180. `warnAt` 에 발송한다. */
|
||||
/** 사전 알림 시간(초). 정책 원장 C-22 「발생 3분 전 푸시」 = 180. `warnAt` 에 발송한다. */
|
||||
warnSeconds: number;
|
||||
/** 평균 DAU 대비 고유 유효 참여자 목표율. 원장 C-25 기본 5%. */
|
||||
/** 평균 DAU 대비 고유 유효 참여자 목표율. 정책 원장 C-25 기본 5%. */
|
||||
targetRate: number;
|
||||
/** 목표 분모의 DAU 평균 기간(일). 원장 C-21 기본 7일 · 운영값. */
|
||||
/** 목표 분모의 DAU 평균 기간(일). 정책 원장 C-21 기본 7일 · 운영값. */
|
||||
dauWindowDays: number;
|
||||
/** 공통 방어 창(초). 원장 C-22 기본 60초 · 운영값. */
|
||||
/** 공통 방어 창(초). 정책 원장 C-22 기본 60초 · 운영값. */
|
||||
windowSec: number;
|
||||
/** 고유 참여자 1명으로 인정하는 개인 유효 Shake 누적. 원장 C-25 기본 60회 · 운영값. */
|
||||
/** 고유 참여자 1명으로 인정하는 개인 유효 Shake 누적. 정책 원장 C-25 기본 60회 · 운영값. */
|
||||
qualifyShakes: number;
|
||||
/** 한 호출이 실을 수 있는 최대 흔들기 수. 서버 상한이며 단말 값을 믿지 않는다. */
|
||||
perCallMaxShakes: number;
|
||||
@@ -391,7 +391,7 @@ function kstMoment(day: string, slot: EclipseSlot | undefined): Date | null {
|
||||
/**
|
||||
* `fn-eclipse-trigger` — 이클립스 발동 · 사전 알림 · 종료.
|
||||
*
|
||||
* 원장 **C-24** — *기본 KST 07:50 · 17:50, 팬덤별 발생 횟수 · 각 시각 별도 설정, 미설정 시
|
||||
* 정책 원장 **C-24** — *기본 KST 07:50 · 17:50, 팬덤별 발생 횟수 · 각 시각 별도 설정, 미설정 시
|
||||
* 공통값. 하루 2회는 고정 제한이 아님. 일정 변경 시 푸시 · 종료 시각 함께 계산*.
|
||||
*
|
||||
* **중복 발동을 조건문으로 막지 않는다.** 문서 ID 를 `{KST 날짜}-{회차}` 로 고정하고
|
||||
@@ -400,7 +400,7 @@ function kstMoment(day: string, slot: EclipseSlot | undefined): Date | null {
|
||||
*
|
||||
* **사전 알림 시각부터 문서를 만들고 `status: 'scheduled'` 로 둔다.** 발동 시각에야
|
||||
* 문서를 만들면 `warnAt` 이 이미 지난 시각이라 사전 알림을 보낼 수 없다. 푸시
|
||||
* 예고(원장 C-22 「발생 3분 전 푸시」)는 이 함수가 `warnAt` 에 바로 보내고, 앱의 경보
|
||||
* 예고(정책 원장 C-22 「발생 3분 전 푸시」)는 이 함수가 `warnAt` 에 바로 보내고, 앱의 경보
|
||||
* 배너는 `active` 만 구독하므로 시작 전에 배너가 뜨지는 않는다.
|
||||
*
|
||||
* 시작 시 FPI 스냅샷만 남기고, 마감 후 실패 확정 때에만 그 기준으로 차감한다.
|
||||
@@ -602,7 +602,7 @@ async function closeExpiredEclipses(
|
||||
occurredAt, createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
tx.update(fandomRef, {
|
||||
// FPI 는 0 아래로 내려가지 않는다(리뷰 M2). 감소량 기록(penalty)은 그대로 남긴다.
|
||||
// FPI 는 0 아래로 내려가지 않는다. 감소량 기록(penalty)은 그대로 남긴다.
|
||||
fpiScore: Math.max(0, (fandom.get('fpiScore') as number) - penalty),
|
||||
eclipsePenaltyRevision: FieldValue.increment(1),
|
||||
});
|
||||
@@ -938,7 +938,7 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
|
||||
tx.set(fandom.ref, { sfpi: FieldValue.increment(fpiScore) }, { merge: true });
|
||||
});
|
||||
}
|
||||
// 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다.
|
||||
// 정책 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다.
|
||||
const promoted = await promoteStages();
|
||||
return { day, fandoms: fandoms.size, promoted: promoted.length };
|
||||
}
|
||||
@@ -1134,7 +1134,7 @@ export const fnCampaignFund = onCall(async (req) => fundCampaign({
|
||||
/**
|
||||
* 후원 한 건의 코어. 테스트가 HTTP 왕복 없이 부르도록 떼어 둔다.
|
||||
*
|
||||
* - **기여 POP 만 쓴다**(원장 B-33). 클라이언트가 보낸 `popType` 을 따르지 않는다.
|
||||
* - **기여 POP 만 쓴다**(정책 원장 B-33). 클라이언트가 보낸 `popType` 을 따르지 않는다.
|
||||
* - 목표는 규칙·설계서의 스키마 이름 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만
|
||||
* 읽는다 — 예전에는 `goalPop` 만 읽어 목표 초과 차단(E-CMP-01)이 한 번도 걸리지 않았다.
|
||||
* - 성장 귀속 팬덤은 **캠페인 문서의 `fandomId`** 다. 클라이언트가 보낸 값을 믿지 않는다.
|
||||
|
||||
@@ -25,7 +25,7 @@ interface DropConfig {
|
||||
* 총량 하나에 운영값(1인 상한 % · 시간)을 붙여 Drop 규격을 만든다.
|
||||
*
|
||||
* [totalPop] 을 받으면 운영 기본 총량 대신 그 값을 쓴다 — Hero Gift 구매는 상품별
|
||||
* Admin 확정값이 총량이다(원장 B-84 · B-85).
|
||||
* Admin 확정값이 총량이다(정책 원장 B-84 · B-85).
|
||||
*/
|
||||
export async function dropConfig(totalPop?: number): Promise<DropConfig> {
|
||||
const snap = await getFirestore().doc('config/policy').get();
|
||||
|
||||
@@ -522,7 +522,7 @@ async function spendParts(
|
||||
if (growthSpend) {
|
||||
// FPI 는 기여 **행위 단위**다(C-34). 금액이 아니라 Zap 횟수(`fn-pop-spend` 가 서버에서
|
||||
// 금액 ÷ zapPop 으로 계산) · 후원 1건. EXP 는 주지 않는다 — POP 을 쓰는 행위는 1 POP
|
||||
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18 · 리뷰 B1).
|
||||
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18).
|
||||
writeFpiEvent(tx, {
|
||||
uid: input.uid, fandomId: fandomId ?? null, axis: 'contribution',
|
||||
units: input.reasonCode === 'ZAP_CONTRIBUTION' ? input.count ?? 1 : 1,
|
||||
|
||||
@@ -47,7 +47,7 @@ export const fnPopGrant = onCall(async (req) => {
|
||||
* 사용자가 직접 부를 수 있는 차감 사유 — **허용 목록**.
|
||||
*
|
||||
* 다른 사유는 각자의 검증 함수를 지나야 한다. 예전에는 사유를 그대로 받아 `CAMPAIGN_FUND`
|
||||
* 를 캠페인 검증 없이 쓰거나(B-33 · B-34 우회) 원장 사유를 위조할 수 있었다(리뷰 H1 · R03).
|
||||
* 를 캠페인 검증 없이 쓰거나(B-33 · B-34 우회) 원장 사유를 위조할 수 있었다.
|
||||
*/
|
||||
const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
|
||||
|
||||
@@ -55,9 +55,9 @@ const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
|
||||
* `fn-pop-spend` — 본인 계정에서만 차감한다. 지금 열린 사유는 Zap 하나다.
|
||||
*
|
||||
* **Zap 횟수는 서버가 센다** — `금액 ÷ zapPop`(운영값). 클라이언트 `count` 를 믿으면
|
||||
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤은 Zap 한 행성
|
||||
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다. 귀속 팬덤은 Zap 한 행성
|
||||
* `fandomId` 다(상세정의 5장 · 10/3 #10). 원장이 같은 트랜잭션에서 가입 여부를 확인하므로
|
||||
* 가입하지 않은 행성은 거부된다(리뷰 M1 — 남의 행성 성장을 올리는 길을 막는다).
|
||||
* 가입하지 않은 행성은 거부된다(남의 행성 성장을 올리는 길을 막는다).
|
||||
*/
|
||||
export const fnPopSpend = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function sendUserPush(
|
||||
}
|
||||
|
||||
/**
|
||||
* 이클립스 사전 알림 — 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」.
|
||||
* 이클립스 사전 알림 — 정책 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」.
|
||||
*
|
||||
* `fn-eclipse-trigger` 가 회차의 `warnAt`(시작 − `warnSeconds`) 이 지난 첫 점검에서
|
||||
* 부른다. 활성화 뒤에 보내면 예고가 아니라 사후 통지가 된다.
|
||||
|
||||
@@ -48,7 +48,7 @@ function platformOf(data: unknown): Platform {
|
||||
const iapKey = (claim: ReceiptClaim) =>
|
||||
`iap:${claim.platform}:${claim.transactionId}`;
|
||||
|
||||
/** POP 묶음 상품 ID 접두 — 원장 B-02 로 기본 판매 중지(`popPackSalesEnabled`). */
|
||||
/** POP 묶음 상품 ID 접두 — 정책 원장 B-02 로 기본 판매 중지(`popPackSalesEnabled`). */
|
||||
const POP_PACK_PREFIX = 'pop.support.';
|
||||
|
||||
const PAY_METHOD: Readonly<Record<Platform, string>> = {
|
||||
@@ -59,7 +59,7 @@ const PAY_METHOD: Readonly<Record<Platform, string>> = {
|
||||
interface HeroGiftProduct { name: string; buyerPop: number; dropPop: number }
|
||||
|
||||
/**
|
||||
* Hero Gift 상품 Catalog — 원장 B-85. 문서 ID 가 스토어 상품 ID 다.
|
||||
* Hero Gift 상품 Catalog — 정책 원장 B-85. 문서 ID 가 스토어 상품 ID 다.
|
||||
*
|
||||
* Admin `관리 마스터_아이템 관리` 가 `fnAdminWrite` 로 쓴다. 상품을 늘려도 앱을
|
||||
* 다시 배포하지 않는다(B-40 검증 기준). Catalog 에 없으면 null — 일반 상품 표로 넘어간다.
|
||||
@@ -408,7 +408,7 @@ export const fnSubscriptionWebhook = onCall(async (req) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-focus-daily-claim` — Focus 구독 혜택, 원장 **B-04**.
|
||||
* `fn-focus-daily-claim` — Focus 구독 혜택, 정책 원장 **B-04**.
|
||||
*
|
||||
* *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음.* 하루는 서버 일 단위(KST
|
||||
* `dayKey`, 퀘스트 한도와 같은 경계)다. 멱등키가 `focus:{uid}:{day}` 라 같은 날 두 번 지급되지
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||
import { callerUid, str } from '../core/callable.js';
|
||||
|
||||
/**
|
||||
* `fn-link-preview` — 링크 기반 Quest 의 원본 제목 · 썸네일 · 서비스명(원장 **C-05** · R10).
|
||||
* `fn-link-preview` — 링크 기반 Quest 의 원본 제목 · 썸네일 · 서비스명(정책 원장 **C-05** · R10).
|
||||
*
|
||||
* 서버가 사용자가 준 URL 을 대신 여는 경로라 **SSRF 방어가 이 파일의 본체**다.
|
||||
* - http / https 만, 계정 정보(`user:pass@`) · 기본 외 포트 거부
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function dailyLimitOf(questId: string): Promise<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여할 퀘스트 문서를 확인한다 — 리뷰 H2 · B2.
|
||||
* 참여할 퀘스트 문서를 확인한다.
|
||||
*
|
||||
* **문서가 없거나 유형이 다르면 거부한다.** 예전에는 `questId` 가 진행 문서 ID 로만 쓰여
|
||||
* 있지도 않은 ID 를 바꿔 가며 부르면 일일 한도 · 1회 제한이 매번 새로 열렸다.
|
||||
@@ -140,7 +140,7 @@ function count(data: Record<string, unknown>, key: string, max: number): number
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-quest-progress` — Quest 진행 상태(진행중 · 일시 정지 · 진행완료)를 진행 문서에 남긴다(원장 C-83).
|
||||
* `fn-quest-progress` — Quest 진행 상태(진행중 · 일시 정지 · 진행완료)를 진행 문서에 남긴다(정책 원장 C-83).
|
||||
*
|
||||
* 화면을 다시 열거나 앱을 다시 켜도 「진행중」 탭 · 「이어하기」 · 목록 「보상받기」가 유지되게 하는
|
||||
* **표시용 상태**다. 지급 판정은 여기서 하지 않는다 — 보상은 그대로 검증 함수가 증거로 판정한다.
|
||||
@@ -271,7 +271,7 @@ export const fnQuestAttend = onCall(async (req) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-quest-stream-participate` — Streaming · YouTube 시청 Quest **무보상 기본 참여**(원장 C-04 · B-25).
|
||||
* `fn-quest-stream-participate` — Streaming · YouTube 시청 Quest **무보상 기본 참여**(정책 원장 C-04 · B-25).
|
||||
*
|
||||
* POP 원장을 쓰지 않는다(검증 기준 「POP 지급 원장 0건」). 참여 기록(`questProgress`)과 FPI 원천
|
||||
* 이벤트만 같은 트랜잭션에 남긴다 — 보상이 없는 유효 활동도 FPI 에 들어간다(C-34).
|
||||
@@ -320,7 +320,7 @@ export const fnQuestVerifySteps = onCall(async (req) => {
|
||||
const at = occurredAt(req.data, now);
|
||||
const key = dayKey(at);
|
||||
// 퀘스트 문서가 있어야 하고 걸음 유형이어야 한다. 없는 ID 로 바꿔 부르면 진행 문서 · 멱등키가
|
||||
// 새로 열려 구간 보상이 다시 나간다(리뷰 재검토 blocker). 대표 팬덤 자격(C-16)도 본다.
|
||||
// 새로 열려 구간 보상이 다시 나간다. 대표 팬덤 자격(C-16)도 본다.
|
||||
const questId = str(req.data, 'questId');
|
||||
const quest = await questOf(uid, questId, ['walk', 'steps', 'health']);
|
||||
const steps = positiveInt(num(req.data, 'steps'), 'steps');
|
||||
@@ -414,7 +414,7 @@ export const fnQuestVerifySteps = onCall(async (req) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 광고 증분 — 원장 B-03 · C-02(B-28).
|
||||
* 광고 증분 — 정책 원장 B-03 · C-02(B-28).
|
||||
*
|
||||
* - 사용자 생성 Quest(`authorUid` 있음): 고정 [PolicyConfig.adRewardPop] 기여 POP.
|
||||
* - 운영 Quest: 기본 보상 × (배수 − 1) 기여 POP. 배수는 Quest 지정값(`adMultiplier`,
|
||||
@@ -449,7 +449,7 @@ function questIdOfKey(key: unknown): string | null {
|
||||
* 앱은 방금 받은 **기본 보상의 원장 txId**(`baseTxId`)만 보낸다. 서버는 그 원장 행이 본인 · 오늘 ·
|
||||
* 퀘스트 보상인지 확인하고, **AdMob 서버측 검증(SSV) 기록**(`adSsv`, [fnAdSsv])이 그 기본 보상에
|
||||
* 붙어 있을 때만 증분을 기여 POP 으로 지급한다. 앱이 보낸 노출 ID 는 근거로 쓰지 않는다 — 함수만
|
||||
* 불러서는 기여 POP 이 나가지 않는다(원장 B-03 「광고 검증 실패 시 증분 미지급 · 횟수 미차감」).
|
||||
* 불러서는 기여 POP 이 나가지 않는다(정책 원장 B-03 「광고 검증 실패 시 증분 미지급 · 횟수 미차감」).
|
||||
* 기본 보상 한 건에 광고 보상은 한 번이다(`users/{uid}/adBonuses/{baseTxId}`).
|
||||
*
|
||||
* - SSV 콜백이 아직 오지 않았으면 E-AD-03(횟수 미차감). 앱은 잠시 뒤 다시 부른다.
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface ShakeJudgement {
|
||||
export type { ShakeGrade };
|
||||
|
||||
/**
|
||||
* 판정표 → 등급. F 면 실패(보상 0 — 원장 B-24). 문턱은 **운영값**(`config/policy.musicShake`)이며
|
||||
* 판정표 → 등급. F 면 실패(보상 0 — 정책 원장 B-24). 문턱은 **운영값**(`config/policy.musicShake`)이며
|
||||
* 🟡 임시값 — 회신 대기다(근거 없음 · 해석 확인 요청 2026-10-07 Q-MS-05).
|
||||
*/
|
||||
export function gradeShake(
|
||||
@@ -93,7 +93,7 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
||||
const now = new Date();
|
||||
const data = (req.data ?? {}) as Record<string, unknown>;
|
||||
// 곡과 하루 한도는 **퀘스트 문서**가 정한다. 예전 멱등키는 `trackId · 날짜` 라 곡 ID 만
|
||||
// 바꿔 부르면 하루에 무제한 지급됐다(리뷰 재검토 blocker). 곡은 퀘스트의 카탈로그와 대조하고,
|
||||
// 바꿔 부르면 하루에 무제한 지급됐다. 곡은 퀘스트의 카탈로그와 대조하고,
|
||||
// 구간 수도 퀘스트에 정의돼 있으면 그 값만 받는다 — 판정표를 줄여 등급을 올리지 못하게.
|
||||
const questId = str(req.data, 'questId');
|
||||
const quest = await questOf(uid, questId, ['musicshake', 'music_shake', 'shake']);
|
||||
@@ -160,7 +160,7 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
||||
fail(ERR.questIncomplete, '박자 판정이 성공 기준에 미치지 못했습니다.');
|
||||
}
|
||||
|
||||
// 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
|
||||
// 정책 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
|
||||
// `config/policy.musicShake.pop` 으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`).
|
||||
// 등급 배수는 🟡 임시값 — 회신 대기(Q-MS-05).
|
||||
const base = quest.get('rewardPop');
|
||||
@@ -224,7 +224,7 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
|
||||
const lng = num(req.data, 'lng');
|
||||
// 참여 횟수는 Quest 단위다(C-12 — 같은 장소의 다른 Quest 는 별개). 그래서 퀘스트 문서가
|
||||
// 반드시 있어야 하고 **그 퀘스트의 장소**여야 한다 — 없는 ID 로 바꿔 부르면 1회 제한이
|
||||
// 매번 새로 열린다(리뷰 B2). 대표 팬덤 자격(C-16)도 여기서 본다.
|
||||
// 매번 새로 열린다. 대표 팬덤 자격(C-16)도 여기서 본다.
|
||||
const questId = str(req.data, 'questId');
|
||||
const questDoc = await questOf(uid, questId, ['place', 'location']);
|
||||
if (questDoc.get('authorUid') === uid) fail(ERR.permissionDenied, '내가 만든 퀘스트에는 참여할 수 없습니다.');
|
||||
|
||||
@@ -46,7 +46,7 @@ it('ops 는 master 전용이 아닌 문서는 그대로 쓴다 · 역할 없는
|
||||
expect(await denials('admin-none')).toBe(1);
|
||||
});
|
||||
|
||||
it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못 쓴다 · Eclipse 운영값은 master 만(리뷰 H3)', async () => {
|
||||
it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못 쓴다 · Eclipse 운영값은 master 만', async () => {
|
||||
const denied = (role: string, path: string, patch?: Record<string, unknown>) =>
|
||||
expect(run(role, path, patch)).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
|
||||
await denied('master', 'fandoms/f1/fpiEvents/e1');
|
||||
|
||||
@@ -360,7 +360,7 @@ describe('푸시 토픽', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('이클립스 사전 알림 — 시작 − 3분 (원장 C-22)', () => {
|
||||
describe('이클립스 사전 알림 — 시작 − 3분 (정책 원장 C-22)', () => {
|
||||
/// 예약 회차 한 건을 심는다. [startInMin] 분 뒤 시작, 예고는 그 3분 전이다.
|
||||
async function round(id: string, startInMin: number | null, extra: Record<string, unknown> = {}) {
|
||||
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${id}`);
|
||||
|
||||
@@ -16,9 +16,9 @@ import { aggregateActiveEclipses, defendEclipse } from '../src/fandom/fandom.js'
|
||||
* 보는 것
|
||||
* 1. **POP 이 움직이지 않는다** — 지갑과 원장이 그대로다.
|
||||
* 2. **분산 카운터 유실 0** — 샤드를 흩어도 합이 참여 총량과 같다(별첨 1 ① 예외 처리).
|
||||
* 3. **멱등** — 재시도가 샤드·방어 로그·티커 이벤트를 두 번 올리지 않는다(검수 D1·D6).
|
||||
* 3. **멱등** — 재시도가 샤드·방어 로그·티커 이벤트를 두 번 올리지 않는다.
|
||||
* 4. **서버 검증** — 단말이 보낸 흔들기 수를 상한과 속도로 다시 판정한다(별첨 1 ②).
|
||||
* 5. **즉시 성공 판정** — 목표에 닿은 순간 `defended` 가 선다(검수 D2).
|
||||
* 5. **즉시 성공 판정** — 목표에 닿은 순간 `defended` 가 선다.
|
||||
*/
|
||||
|
||||
const FANDOM = 'test-ecl-fandom';
|
||||
@@ -195,7 +195,7 @@ describe('분산 카운터 — 카운트 유실 0 (별첨 1 ① 예외 처리)',
|
||||
});
|
||||
});
|
||||
|
||||
describe('멱등 — 재시도가 이중 계산되지 않는다 (검수 D1·D6)', () => {
|
||||
describe('멱등 — 재시도가 이중 계산되지 않는다', () => {
|
||||
it('같은 clientSeq 를 두 번 보내도 샤드·로그·티커가 한 번만 늘어난다', async () => {
|
||||
await seed();
|
||||
const first = await defendEclipse({
|
||||
@@ -338,7 +338,7 @@ describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('성공 판정 — 목표에 닿은 즉시 (검수 D2)', () => {
|
||||
describe('성공 판정 — 목표에 닿은 즉시', () => {
|
||||
it('한 사람의 추가 Shake는 목표 두 명을 채울 수 없고 두 번째 60회에서만 성공한다', async () => {
|
||||
await seed({ target: 2, perCallMaxShakes: 60 });
|
||||
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
|
||||
@@ -485,7 +485,7 @@ describe('v1.5 유효 참여의 원자적 기록', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('개인 인정 횟수는 운영값이다 (원장 C-25)', () => {
|
||||
describe('개인 인정 횟수는 운영값이다 (정책 원장 C-25)', () => {
|
||||
it('회차에 고정된 인정 횟수로 판정하고 운영값 문서만 바꿔서는 진행 중 회차 기준이 바뀌지 않는다', async () => {
|
||||
await seed({ target: 1, perCallMaxShakes: 60, qualifyShakes: 60 });
|
||||
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
|
||||
|
||||
@@ -146,7 +146,7 @@ beforeEach(async () => {
|
||||
await wipeFandom();
|
||||
});
|
||||
|
||||
describe('발동 — 하루 정확히 2 회 (원장 B-55)', () => {
|
||||
describe('발동 — 하루 정확히 2 회 (정책 원장 B-55)', () => {
|
||||
it('DAU 누락 회차는 취소 사유를 남기고 활성화·FPI 차감 없이 종료한다', async () => {
|
||||
await seedFandom();
|
||||
await aggregateFpi();
|
||||
@@ -278,7 +278,7 @@ describe('발동 — 하루 정확히 2 회 (원장 B-55)', () => {
|
||||
});
|
||||
|
||||
it('발동 시각·침식률을 운영값으로 바꾸면 재배포 없이 따라간다', async () => {
|
||||
// 원장 B-55 검증 기준 「방어 수치가 Admin 에서 앱 재배포 없이 변경됨」.
|
||||
// 정책 원장 B-55 검증 기준 「방어 수치가 Admin 에서 앱 재배포 없이 변경됨」.
|
||||
await seedFandom({ schedule: [{ hhmm: '06:30' }], erosionPercent: 1, warnSeconds: 60 });
|
||||
await aggregateFpi();
|
||||
|
||||
@@ -318,14 +318,14 @@ describe('발동 — 하루 정확히 2 회 (원장 B-55)', () => {
|
||||
.get('status')).toBe('active');
|
||||
|
||||
await runEclipseSchedule(kst(DAY, '13:31'));
|
||||
// 검수 D3 — 마감이 지나면 `ended` 로 닫아 배너 질의에서 빠진다.
|
||||
// 마감이 지나면 `ended` 로 닫아 배너 질의에서 빠진다.
|
||||
expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get())
|
||||
.get('status')).toBe('ended');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('일정 — 배열 길이 = 하루 회차 수 · 팬덤별 우선 (원장 C-24)', () => {
|
||||
describe('일정 — 배열 길이 = 하루 회차 수 · 팬덤별 우선 (정책 원장 C-24)', () => {
|
||||
const ids = async () => (await getFirestore().collection(`fandoms/${FANDOM}/eclipseRounds`).get())
|
||||
.docs.map((d) => [d.id, (d.get('startAt') as Timestamp).toMillis()]).sort();
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ it('KST 자정 실행은 전날 키로 스냅샷 · DAU 를 남기고 DAU 는
|
||||
expect((await ref.get()).get('sfpi')).toBe(50 + 125 * 3);
|
||||
});
|
||||
|
||||
it('Eclipse 차감만 있는 팬덤은 FPI 0 아래로 내려가지 않고 랭킹 모집단에 들지 않는다(리뷰 M2)', async () => {
|
||||
it('Eclipse 차감만 있는 팬덤은 FPI 0 아래로 내려가지 않고 랭킹 모집단에 들지 않는다', async () => {
|
||||
const ref = getFirestore().doc(`fandoms/${id}`);
|
||||
await ref.set({ stage: 1 }, { merge: true });
|
||||
await ref.collection('fpiEvents').add({
|
||||
|
||||
@@ -155,7 +155,7 @@ it('캠페인 후원은 기여 POP 만 쓰고 targetPop 초과를 막으며 캠
|
||||
await db.recursiveDelete(db.doc(`campaigns/${cid}`));
|
||||
});
|
||||
|
||||
it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입 행성에 귀속하고 EXP 를 주지 않는다(리뷰 B1 · H1 · M1)', async () => {
|
||||
it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입 행성에 귀속하고 EXP 를 주지 않는다', async () => {
|
||||
const db = getFirestore();
|
||||
const { fnPopSpend } = await import('../src/ledger/pop.js');
|
||||
const run = (data: Record<string, unknown>) => fnPopSpend.run(
|
||||
@@ -168,7 +168,7 @@ it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입
|
||||
await expect(run({ popType: 'activity', amount: 5, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:odd' }))
|
||||
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
|
||||
|
||||
// 가입하지 않은 행성 b 에는 Zap 할 수 없다 — 남의 행성 성장을 올리지 못한다(리뷰 M1).
|
||||
// 가입하지 않은 행성 b 에는 Zap 할 수 없다 — 남의 행성 성장을 올리지 못한다.
|
||||
await expect(run({ popType: 'activity', amount: 30, fandomId: b,
|
||||
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b0' }))
|
||||
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('차감', () => {
|
||||
const user = await db.doc(`users/${UID}`).get();
|
||||
expect(user.get('totalContributedPop')).toBe(40);
|
||||
// 기여 POP 문턱 등급은 폐기됐다(C-18). 후원 · Zap 은 1 POP 단위로 쪼개 반복할 수 있어
|
||||
// EXP 를 주지 않는다(리뷰 B1) — EXP 는 Quest 인정 활동에서만 쌓인다.
|
||||
// EXP 를 주지 않는다 — EXP 는 Quest 인정 활동에서만 쌓인다.
|
||||
expect(user.get('tier')).toBeUndefined();
|
||||
expect(user.get('userExp')).toBeUndefined();
|
||||
const member = await db.doc(`fandoms/fandom-a/members/${UID}`).get();
|
||||
@@ -279,7 +279,7 @@ describe('User EXP · Level · Passport 갱신 (C-18 · B-65)', () => {
|
||||
});
|
||||
const user = await getFirestore().doc(`users/${UID}`).get();
|
||||
expect(user.get('totalContributedPop')).toBe(3_000);
|
||||
// 3,000 POP 후원도 EXP 를 만들지 않는다(C-18 기여 POP 치환 금지 · 리뷰 B1).
|
||||
// 3,000 POP 후원도 EXP 를 만들지 않는다(C-18 기여 POP 치환 금지).
|
||||
expect(user.get('userExp')).toBeUndefined();
|
||||
expect(user.get('passportTier')).toBeUndefined();
|
||||
expect(user.get('tier')).toBeUndefined();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
fnFandomJoin, fnFandomLeave, fnNebulaCreate, fnPlanetCreate, fnPrimaryFandomSet, isValidNebulaName,
|
||||
} from '../src/account/account.js';
|
||||
|
||||
/** 성운 생성 → 가입 · 대표 팬덤 → 행성 탄생 — 원장 B-08 · B-09 · B-10 · C-15 · C-50. */
|
||||
/** 성운 생성 → 가입 · 대표 팬덤 → 행성 탄생 — 정책 원장 B-08 · B-09 · B-10 · C-15 · C-50. */
|
||||
const UID = 'test-planet-create';
|
||||
const OTHER = 'test-planet-create-other';
|
||||
const db = () => getFirestore();
|
||||
@@ -143,7 +143,7 @@ describe('가입 · 탈퇴 · 대표 팬덤', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('창립자 탈퇴 승계 · 대표 팬덤 자동 지정(원장 C-100 · C-101 · S20)', () => {
|
||||
describe('창립자 탈퇴 승계 · 대표 팬덤 자동 지정(정책 원장 C-100 · C-101 · S20)', () => {
|
||||
const setPolicy = (patch: Record<string, unknown>) => db().doc('config/policy').set(patch, { merge: true });
|
||||
const THIRD = 'test-planet-create-third';
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('참여형 번역 등록 (FR-I18N-02 · B-63)', () => {
|
||||
expect(translationSettleOf({ ...base, valid: 3, invalid: 5, ageHours: 80 })).toBe('release');
|
||||
});
|
||||
|
||||
it('보상 기본값은 원장 B-63 의 5 POP 이다', () => {
|
||||
it('보상 기본값은 정책 원장 B-63 의 5 POP 이다', () => {
|
||||
expect(DEFAULT_POLICY.translationPop).toBe(5);
|
||||
});
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
describe('출석 — S-H-ATTEND (fn-quest-attend)', () => {
|
||||
it('없는 퀘스트 · 출석이 아닌 퀘스트 ID 로는 지급하지 않는다(리뷰 H2)', async () => {
|
||||
it('없는 퀘스트 · 출석이 아닌 퀘스트 ID 로는 지급하지 않는다', async () => {
|
||||
expect(await codeOf(() => call(fnQuestAttend, { questId: 'no-such-quest' }))).toBe('E-QST-01');
|
||||
expect(await codeOf(() => call(fnQuestAttend, { questId: STEPS_QUEST }))).toBe('E-QST-01');
|
||||
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
|
||||
@@ -314,7 +314,7 @@ describe('만보기 — S-H-WALK (fn-quest-verify-steps · 정책 원장 B-26)',
|
||||
});
|
||||
});
|
||||
|
||||
describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리뷰 재검토)', () => {
|
||||
describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다', () => {
|
||||
it('걸음: 없는 퀘스트 · 걸음이 아닌 퀘스트 ID 로는 지급하지 않는다', async () => {
|
||||
expect(await codeOf(() => call(fnQuestVerifySteps, { questId: 'no-such-walk', steps: 5_200 })))
|
||||
.toBe('E-QST-01');
|
||||
@@ -534,7 +534,7 @@ describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
|
||||
expect((await progress.get()).get('count')).toBe(2);
|
||||
});
|
||||
|
||||
it('없는 퀘스트 · 다른 장소의 퀘스트 · 다른 팬덤 퀘스트로는 지급하지 않는다(리뷰 B2 · C-16)', async () => {
|
||||
it('없는 퀘스트 · 다른 장소의 퀘스트 · 다른 팬덤 퀘스트로는 지급하지 않는다(C-16)', async () => {
|
||||
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'no-such-quest' })))
|
||||
.toBe('E-QST-01');
|
||||
await db().doc('quests/place-elsewhere').set({ type: 'location', placeId: 'other-place' });
|
||||
@@ -725,7 +725,7 @@ describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => {
|
||||
// 후원할 금액을 만든다. 지갑만 올리지 않고 원장을 지난다 — 3자 대조가 깨지면
|
||||
// 차감이 E-POP-02 로 막힌다.
|
||||
await grantPop({
|
||||
// 후원은 기여 POP 만 쓴다(원장 B-33) — 기여 잔액을 만든다.
|
||||
// 후원은 기여 POP 만 쓴다(정책 원장 B-33) — 기여 잔액을 만든다.
|
||||
uid: UID, popType: 'support', amount: 3_000,
|
||||
reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:${UID}`,
|
||||
});
|
||||
@@ -746,7 +746,7 @@ describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => {
|
||||
expect(user.get('totalContributedPop')).toBe(3_000);
|
||||
// EXP 는 원장이 움직일 때 함께 갱신된다 — 출석 보상 10 + 후원 3,000(C-18 단순안).
|
||||
expect(user.get('tier')).toBeUndefined();
|
||||
// EXP 는 출석(인정 활동 1건)만 — 후원은 EXP 대상이 아니다(리뷰 B1).
|
||||
// EXP 는 출석(인정 활동 1건)만 — 후원은 EXP 대상이 아니다.
|
||||
expect(user.get('userExp')).toBe(100);
|
||||
expect(user.get('userLevel')).toBe(1);
|
||||
const member = await db().doc(`fandoms/${FANDOM}/members/${UID}`).get();
|
||||
@@ -765,7 +765,7 @@ describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('진행 상태 — 일시 정지 · 이어하기 · 진행완료 (fn-quest-progress · 원장 C-83)', () => {
|
||||
describe('진행 상태 — 일시 정지 · 이어하기 · 진행완료 (fn-quest-progress · 정책 원장 C-83)', () => {
|
||||
const ref = () => db().doc(`users/${UID}/questProgress/${STEPS_QUEST}`);
|
||||
|
||||
it('진행중 → 진행완료를 남기고, 일일 한도 · 완료 횟수는 건드리지 않는다', async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { grantPop, reconcileUser, settlePendingGrant } from '../src/ledger/ledge
|
||||
|
||||
/**
|
||||
* 참여형 번역 왕복 — 등록(보류 지급) → 마킹(자격 판정) → 24시간 뒤 정산(확정/해제).
|
||||
* 원장 B-63: 1건 5 POP · 24시간 확정대기 · 자기 마킹 제외 · 중복 0 · 일일상한.
|
||||
* 정책 원장 B-63: 1건 5 POP · 24시간 확정대기 · 자기 마킹 제외 · 중복 0 · 일일상한.
|
||||
*/
|
||||
|
||||
const AUTHOR = 'test-trn-author';
|
||||
|
||||
Reference in New Issue
Block a user