코드 주석을 이 저장소 기준으로 정리

This commit is contained in:
Sn-Kinos
2026-10-16 18:04:14 +09:00
parent 80b90aad4c
commit 4236d75fd1
89 changed files with 212 additions and 212 deletions
+4 -4
View File
@@ -228,7 +228,7 @@ service cloud.firestore {
// 생성 주체를 Auth 트리거 하나로 고정한다.
allow create: if false;
// 클라이언트가 바꿀 수 있는 것은 프로필 표시 항목뿐이다.
// `nickname` 은 설계서 v1.1 §2.1 이 정한 프로필 표시 이름이다(원장 B-52 —
// `nickname` 은 설계서 v1.1 §2.1 이 정한 프로필 표시 이름이다(정책 원장 B-52 —
// 2~20자 · 중복 허용 · 유니크 인덱스 제거). 목록에 없으면 MY 의 닉네임 변경이
// permission-denied 로 떨어지는데, 원인이 화면처럼 보여 찾기 어렵다.
// `displayName` 은 Auth 트리거가 처음 채우는 필드라(§8.5 fn-auth-oncreate) 함께 남긴다.
@@ -342,7 +342,7 @@ service cloud.firestore {
}
// 결제 기록. `fn-iap-verify` 가 쓰고 환불 회수는 `fn-iap-refund-webhook` 가 바꾼다.
// 화면: MY/설정/서비스 결제 내역 · 상세 · 환불 요청 (원장 B-02 · B-72)
// 화면: MY/설정/서비스 결제 내역 · 상세 · 환불 요청 (정책 원장 B-02 · B-72)
// 본인이 할 수 있는 것은 **지급 완료 → 환불 요청** 한 가지 전이뿐이다. 금액·상태의
// 다른 전이(승인·반려·회수)는 Admin 과 서버가 한다.
match /users/{uid}/purchases/{purchaseId} {
@@ -511,7 +511,7 @@ service cloud.firestore {
allow write: if false; // 수령은 fn-herodrop-claim 경유
}
// Hero Gift 상품 Catalog — 원장 B-85. 문서 ID = 스토어 상품 ID.
// Hero Gift 상품 Catalog — 정책 원장 B-85. 문서 ID = 스토어 상품 ID.
// 화면: 홈 「아이템 구입」 · Admin `관리 마스터_아이템 관리`(fnAdminWrite 경유)
// 지급량(buyerPop · dropPop)은 `fn-iap-verify` 가 여기서 읽으므로 클라이언트 쓰기를 막는다.
match /heroGiftProducts/{productId} {
@@ -529,7 +529,7 @@ service cloud.firestore {
// ───────────────────────── 4. 퀘스트 · 장소 · 브리핑 ─────────────────────────
// 퀘스트 정의·보상 파라미터. 화면: S-H-HOME · S-AD-QUEST
// 위치 Quest 의 QR 코드(원장 C-03) — 생성자가 현장에 붙일 QR 을 띄우려고 읽는다. 참여자가 읽으면
// 위치 Quest 의 QR 코드(정책 원장 C-03) — 생성자가 현장에 붙일 QR 을 띄우려고 읽는다. 참여자가 읽으면
// 찍지 않고 값을 보낼 수 있으므로 생성자 · 조회 등급만. `fn-quest-create` 가 발급하고 고치지 않는다.
match /questSecrets/{questId} {
allow read: if signedIn() && (resource.data.authorUid == request.auth.uid || isViewer());
+13 -13
View File
@@ -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.
*/
+1 -1
View File
@@ -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` 를 한
+7 -7
View File
@@ -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',
+13 -13
View File
@@ -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();
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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`) 이 지난 첫 점검에서
* 부른다. 활성화 뒤에 보내면 예고가 아니라 사후 통지가 된다.
+3 -3
View File
@@ -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@`) · 기본 외 포트 거부
+6 -6
View File
@@ -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(횟수 미차감). 앱은 잠시 뒤 다시 부른다.
+4 -4
View File
@@ -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');
+1 -1
View File
@@ -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}`);
+5 -5
View File
@@ -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({
+2 -2
View File
@@ -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' } });
+2 -2
View File
@@ -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 () => {
+1 -1
View File
@@ -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';
+2 -2
View File
@@ -203,7 +203,7 @@ def main() -> None:
print("▸ 기능 스위치(config/features)")
put(token, "config/features", {"stationEnabled": False})
# 원장 C-01 · C-02 — 4 카테고리 ↔ 10 활동 · 순서 · 노출 · 사용자 생성 허용(S03 초기값).
# 정책 원장 C-01 · C-02 — 4 카테고리 ↔ 10 활동 · 순서 · 노출 · 사용자 생성 허용(S03 초기값).
# 서버 `DEFAULT_QUEST_CATALOG`(functions/src/quest/ugc.ts)와 같은 값이다. 운영 변경은 fn-admin-write.
print("▸ 퀘스트 분류 마스터(config/questCatalog)")
field = lambda f: [{"field": f, "required": True}] if f else []
@@ -409,7 +409,7 @@ def main() -> None:
# 활성이 된다. 발동 스케줄을 확인할 때 이 값을 켠다.
put(token, "config/eclipse", {
"enabled": False,
# 원장 C-24 — 공통 일정(배열 길이 = 하루 회차 수). 팬덤별은 fandoms.eclipseSchedule[].
# 정책 원장 C-24 — 공통 일정(배열 길이 = 하루 회차 수). 팬덤별은 fandoms.eclipseSchedule[].
"schedule": [{"hhmm": "07:50"}, {"hhmm": "17:50"}],
"erosionPercent": 0.25, # C-23 실패 감소율(%)
"warnSeconds": 180, # C-22 발생 3분 전 푸시
+2 -2
View File
@@ -38,7 +38,7 @@ service firebase.storage {
allow delete: if false;
}
// 캠페인 결과보고 집행 사진 · 영수증 — 원장 B-35 · FR-ADM-05 「결과보고는 Admin 수동 운영」.
// 캠페인 결과보고 집행 사진 · 영수증 — 정책 원장 B-35 · FR-ADM-05 「결과보고는 Admin 수동 운영」.
// 운영(master·ops)만 올린다. `fn-campaign-report-submit` 이 이 경로 접두와 파일 존재를
// 다시 본다. 결과보고는 공개 캠페인 상세에 뜨므로 읽기는 열어 둔다. 상한은 B-44 10MB.
match /campaignReports/{campaignId}/{fileName} {
@@ -69,7 +69,7 @@ service firebase.storage {
allow delete: if isSelf(uid);
}
// 위치 Quest 방문 인증 사진 — 원장 C-03 · PRD-05 「GPS · 사진 · QR 인증」. `fn-quest-verify-place` 가
// 위치 Quest 방문 인증 사진 — 정책 원장 C-03 · PRD-05 「GPS · 사진 · QR 인증」. `fn-quest-verify-place` 가
// 이 경로 접두(`questProofs/{본인}/{questId}/`)와 파일 존재를 다시 본다. 운영 검토용이라 본인 · 서버만 읽는다.
// 덮어쓰기 · 삭제 금지 — 제출한 증거가 바뀌면 안 된다. 상한은 B-44 이미지 10MB.
match /questProofs/{uid}/{questId}/{fileName} {
@@ -1785,7 +1785,7 @@ describe('성운 일 1회 기록 — GALAXY 「성운 생성」 버튼(Figma 540
});
});
describe('Quest 진행 세션 — 일시 정지 · 진행완료(원장 C-83 · fn-quest-progress 전용)', () => {
describe('Quest 진행 세션 — 일시 정지 · 진행완료(정책 원장 C-83 · fn-quest-progress 전용)', () => {
test('본인도 진행 세션을 직접 쓰거나 지울 수 없다 — 함수로만', async () => {
await seedDocuments(testEnvironment, {
'users/alice/questProgress/shake': { session: { state: 'paused', progress: 43, total: 60 } },
+1 -1
View File
@@ -211,7 +211,7 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
Future<void> _startPush(PushMessaging push) async {
// 알림 권한은 여기서 묻지 않는다 — 온보딩 ⑥ 권한 안내가 쓰임을 설명한 뒤 묻는다
// (FR-CMN-08 · 원장 C-84, 로그인 직후 일괄 요청 금지). 거부해도 토큰과 구독은
// (FR-CMN-08 · 정책 원장 C-84, 로그인 직후 일괄 요청 금지). 거부해도 토큰과 구독은
// 사용자가 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다.
await push.syncToken();
+1 -1
View File
@@ -1,6 +1,6 @@
import 'backend.dart';
/// 내 초대 코드 — 공유 링크 `?ref=` 에 붙는다(원장 B-69 추천).
/// 내 초대 코드 — 공유 링크 `?ref=` 에 붙는다(정책 원장 B-69 추천).
///
/// 세션당 한 번 `fnInviteCodeGet` 을 부른다(없으면 서버가 만든다). 공유는 이 값을 기다리지
/// 않는다 — 아직 없거나 실패했으면 `?ref=` 없이 나간다. 계정이 바뀌면 다시 받는다.
+1 -1
View File
@@ -34,7 +34,7 @@ abstract interface class RewardedAdHandle {
/// Google Mobile Ads 보상형 광고를 불러온다.
///
/// 불러오지 못하면(채울 광고 없음 · 네트워크 · 시간 초과) `E-AD-01` 로 끊는다 —
/// 원장 B-03 「광고 로드 실패 시 기본 보상은 지급하고 추가 보상·횟수는 차감하지 않음」.
/// 정책 원장 B-03 「광고 로드 실패 시 기본 보상은 지급하고 추가 보상·횟수는 차감하지 않음」.
///
/// [userId] · [customData] 는 **서버측 시청 검증(AdMob SSV)** 에 실린다 — Google 이 시청 완료를
/// 서버(`fn-ad-ssv`)로 직접 알릴 때 이 두 값이 「누가 · 어느 기본 보상(txId)에 붙여」 본 광고인지
+2 -2
View File
@@ -350,10 +350,10 @@ class PushNotice {
/// 라이브 띠 · Push 가 같은 사건을 가리키는 키 — Push `kind` 표기 + 팬덤.
String liveTickerKey(String kind, String fandomId) => '$kind:$fandomId';
/// 원장 C-78 「Push 우선순위」 — ① 위기 ② 공동행동 ③ 성취 ④ 결과, 그 밖(단순 보상 유도형 포함)은 맨 뒤.
/// 정책 원장 C-78 「Push 우선순위」 — ① 위기 ② 공동행동 ③ 성취 ④ 결과, 그 밖(단순 보상 유도형 포함)은 맨 뒤.
enum PushTier { crisis, collective, achievement, result, other }
/// 원장 C-78 단계 배정 — Push 와 알림함이 같은 표를 쓴다.
/// 정책 원장 C-78 단계 배정 — Push 와 알림함이 같은 표를 쓴다.
///
/// - 위기: 이클립스 사전 경고 · 발생
/// - 공동행동: TIME ATTACK 시작 — **임시값(해석 확인 Q-C-10 A안)**
+2 -2
View File
@@ -257,7 +257,7 @@ class AppShell extends StatelessWidget {
final StatefulNavigationShell shell;
final FeatureFlags flags;
/// 일시 정지 Quest 캡슐(원장 C-83)을 읽는 저장소. null 이면 캡슐이 없다.
/// 일시 정지 Quest 캡슐(정책 원장 C-83)을 읽는 저장소. null 이면 캡슐이 없다.
final QuestRepository? quests;
@override
@@ -271,7 +271,7 @@ class AppShell extends StatelessWidget {
return Scaffold(
body: shell,
// Figma 탭 줄은 좌우 16 을 비우고 칸을 나눈다(350:215853).
// 그 바로 위에 일시 정지 Quest 캡슐 한 줄(원장 C-83) — 없으면 높이 0.
// 그 바로 위에 일시 정지 Quest 캡슐 한 줄(정책 원장 C-83) — 없으면 높이 0.
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
+1 -1
View File
@@ -173,7 +173,7 @@ class Planet25D extends StatefulWidget {
/// 이클립스 성공 회차. 바뀌면 광량 복원 뒤 짧은 Orbit Burst(v1.5 §5 「그림자 이탈 → 복원 → Orbit Burst」).
final String? eclipseSuccessId;
/// 사전 경고 창(서버 회차 `warnAt` → `startAt`, 원장 C-22 발생 3분 전). 이 사이에만
/// 사전 경고 창(서버 회차 `warnAt` → `startAt`, 정책 원장 C-22 발생 3분 전). 이 사이에만
/// v1.5 §5 「행성 뒤 어두운 그림자 접근, 별빛·Glow 점진 감소」를 그린다([eclipseApproach]).
final DateTime? eclipseWarnAt;
final DateTime? eclipseStartAt;
+3 -3
View File
@@ -244,7 +244,7 @@ class CampaignDetail {
}
/// 결과보고 — 요구사항 FR-CMP-06 「집행 사진 · 영수증 · 예산 사용 내역 공개」.
/// 서버 문서 `campaigns/{id}/reports/main`. 올리는 쪽은 운영이다(원장 B-35).
/// 서버 문서 `campaigns/{id}/reports/main`. 올리는 쪽은 운영이다(정책 원장 B-35).
class CampaignReport {
const CampaignReport({
required this.body,
@@ -263,10 +263,10 @@ class CampaignReport {
int get budgetTotal => budget.fold(0, (sum, l) => sum + l.amount);
}
/// 사용자 캠페인 등록비 — 원장 B-57(🟡 운영값). 서버 `fn-campaign-create` 가 실제로 뺀다.
/// 사용자 캠페인 등록비 — 정책 원장 B-57(🟡 운영값). 서버 `fn-campaign-create` 가 실제로 뺀다.
const int campaignRegistrationFeePop = 100;
/// Campaign Product Catalog 상품 하나 — 원장 B-59(상품 · 필요 기여 POP) · C-14(권장 모금 기간).
/// Campaign Product Catalog 상품 하나 — 정책 원장 B-59(상품 · 필요 기여 POP) · C-14(권장 모금 기간).
class CampaignProduct {
const CampaignProduct(this.name, this.goalPop, this.recommendedDays);
+2 -2
View File
@@ -74,7 +74,7 @@ class EclipseState {
/// 없으면 이미 시작한 회차로 본다.
final DateTime? startAt;
/// 사전 경고 시각(`warnAt` = 발생 − 사전 알림, 원장 C-22 3분). 행성 그림자 접근의 출발점.
/// 사전 경고 시각(`warnAt` = 발생 − 사전 알림, 정책 원장 C-22 3분). 행성 그림자 접근의 출발점.
final DateTime? warnAt;
/// 서버 회차가 아직 `scheduled`(경보 단계)인가. 방어 호출은 `active` 에서만 받는다.
@@ -94,7 +94,7 @@ class EclipseState {
double get progress =>
targetDefense <= 0 ? 0 : (defense / targetDefense).clamp(0.0, 1.0);
/// 마감이 지났는가. **지난 이클립스는 화면에 띄우지 않는다**(검수 D3) —
/// 마감이 지났는가. **지난 이클립스는 화면에 띄우지 않는다** —
/// 서버가 `ended` 로 닫기 전이라도 화면이 먼저 접는다.
bool isExpired(DateTime now) {
final at = endsAt;
@@ -187,7 +187,7 @@ class FirestoreEclipseRepository implements EclipseRepository {
fandomName: data.strOr('fandomName', fandomId),
erosionRate: data.intOr('erosionRate', 100),
defense: shards.isEmpty ? data.intOr('qualifiedParticipants') : live.$1,
// 원장 C-25·C-26: 인정 횟수 이상인 고유 참여자 수 / DAU 기반 목표.
// 정책 원장 C-25·C-26: 인정 횟수 이상인 고유 참여자 수 / DAU 기반 목표.
targetDefense: data.intOr('target'),
qualifyShakes: data.intOr('qualifyShakes', 60),
defenseCount: shards.isEmpty ? data.intOr('defenseCount') : live.$2,
@@ -16,7 +16,7 @@ import '../../../core/i18n/app_locale.dart';
/// 배너는 세 상태를 그린다.
/// - 활성 이클립스 없음(또는 마감이 지남) → 아무것도 그리지 않는다(빈 [SizedBox]).
/// - 침식 진행 중 → 침식률 · 방어 진행률 · 남은 시간.
/// - 방어 성공(`defended`) → 회복 문구(원장 B-55 「방어 성공 시 침식분 회복」).
/// - 방어 성공(`defended`) → 회복 문구(정책 원장 B-55 「방어 성공 시 침식분 회복」).
///
/// 방어는 **공동 Shake 참여**이며 POP 을 소모하지 않는다. 눌러 들어가는 곳은
/// 「흔들어 방어」 화면이다.
@@ -10,7 +10,7 @@ import '../eclipse_defend_screen.dart';
///
/// 명세 `516:88125` *데이터: 이클립스 발생 잔여 시간 · 규칙: 성운(행성) 아래 중앙 정렬,
/// 어드민에서 설정된 이클립스 이벤트 발생 시간의 N분 전 노출 · 동작: 클릭 시 이클립스 상세로
/// 이동*. N분은 서버 회차의 `warnAt`(원장 C-22 발생 3분 전)이다 — 회차 문서가 그때부터
/// 이동*. N분은 서버 회차의 `warnAt`(정책 원장 C-22 발생 3분 전)이다 — 회차 문서가 그때부터
/// `scheduled` 로 있으므로 그 문서가 보이는 동안 띄운다. 발생 뒤 방어 창(60초) 동안은
/// 남은 초로 바뀌고, 결과가 나거나 창이 닫히면 접는다.
///
@@ -46,7 +46,7 @@ typedef TickerLine = ({String text, bool hero, String? push});
/// ponytail: 전역 하나. HOME 이 띠를 하나만 그리므로 충분하다 — 띠가 여러 곳에 생기면 집합으로.
String? liveTickerShowing;
/// 띠 한 줄 고르기 — 원장 C-78 순서(위기 → 성취)대로 핵심 이벤트가 먼저 오른다.
/// 띠 한 줄 고르기 — 정책 원장 C-78 순서(위기 → 성취)대로 핵심 이벤트가 먼저 오른다.
///
/// 1. 이클립스 발생 소식 — 그 뒤 이벤트(방어 참여 등)가 아직 없을 때.
/// 2. 방어 중 집약 — UX·FX v1.5 §1.1 「개인 이벤트를 연속 티커로 쌓기보다 집단 상태로 집약」 ·
+4 -4
View File
@@ -42,7 +42,7 @@ class PlanetNode {
/// 갤럭시 전체 순위. HUD 의 `Rank`.
///
/// Stage0 성운은 랭킹 모집단이 아니다(원장 B-08 · B-17). 성운에는 순위를 매기지 않고
/// Stage0 성운은 랭킹 모집단이 아니다(정책 원장 B-08 · B-17). 성운에는 순위를 매기지 않고
/// 정식 행성 수 + 1 을 넣어 **가장 바깥 궤도 자리로만** 쓴다 — HUD 는 「-」로 그린다.
/// 최근 7 일 유효 활동이 없는 정식 행성도 같다([ranked]).
final int rank;
@@ -65,7 +65,7 @@ class PlanetNode {
/// 내 행성. 명세 규칙 「행성 — 나의행성 한가운데」.
final bool isMine;
/// 맵 한가운데 서는 행성. 기본은 내 행성이고, Ranking View 에서는 FPI 1위다(원장 B-21).
/// 맵 한가운데 서는 행성. 기본은 내 행성이고, Ranking View 에서는 FPI 1위다(정책 원장 B-21).
final bool isCenter;
}
@@ -128,7 +128,7 @@ class GalaxySnapshot {
PlanetNode? get mine => planets.where((p) => p.isMine).firstOrNull;
/// Ranking View — 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데
/// Ranking View — 정책 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데
/// 두고 나머지는 기본 보기와 같이 순위 궤도를 돈다. Stage0 성운은 랭킹 모집단이 아니므로
/// (B-17) 중심 후보에서 뺀다. 공동 1위면 목록 순서(C-19 고정 정렬키)의 첫 행성이다.
GalaxySnapshot get rankingView {
@@ -201,7 +201,7 @@ abstract interface class GalaxyRepository {
required String clientSeq,
});
/// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면
/// 대표 팬덤 변경 — `fn-primary-fandom-set`(정책 원장 C-15). 30일 대기 중이면
/// E-FDM-04 로 거부된다.
Future<void> setPrimary(String fandomId);
+1 -1
View File
@@ -98,7 +98,7 @@ class _GalaxyScreenState extends State<GalaxyScreen>
PlanetNode? _selected;
int _selectedIndex = 0;
/// Ranking View(원장 B-21). 끄면 기본 보기 — 내 행성 중심.
/// Ranking View(정책 원장 B-21). 끄면 기본 보기 — 내 행성 중심.
bool _ranking = false;
/// 보기를 바꾸면 **새 중심 행성으로 날아간다** — 「Ranking View 전환 시 FPI 상위 중심으로
+4 -4
View File
@@ -100,7 +100,7 @@ class PlanetGrowth {
/// 「행성 성장률 N%」 · 「현재 / 목표」의 기준 — 두 조건 중 **진행률이 낮은 쪽(병목)**.
///
/// Figma 「홈」(`2097:16428` `Frame 1984081073`)은 성장률 하나 · 수치 한 쌍이고, 정책은
/// 두 조건 동시 충족이다(원장 B-11 · 성운은 POP + 창립 인원 B-10). 다음 진화는 늦은 쪽이
/// 두 조건 동시 충족이다(정책 원장 B-11 · 성운은 POP + 창립 인원 B-10). 다음 진화는 늦은 쪽이
/// 채워질 때 오므로 그 축을 보인다. 두 조건 상세는 말풍선을 누르면 연다.
/// ponytail: 해석 확인 전 임시 기준(HOME 정보위계 v1.0 §3 은 「단일 백분율로 합치지 않음」).
/// 값을 모르는 축은 건너뛰고, 둘 다 모르면 null.
@@ -185,7 +185,7 @@ class QuestSummary {
this.pausedSession,
});
/// 일시 정지 세션(원장 C-83). 있으면 보조 줄이 「카테고리 · 일시 정지 · n/m」.
/// 일시 정지 세션(정책 원장 C-83). 있으면 보조 줄이 「카테고리 · 일시 정지 · n/m」.
final QuestSession? pausedSession;
final String title;
@@ -464,7 +464,7 @@ class HomeWeekly {
/// 아이템(Hero Gift) 상품 한 줄 — 사본 `아이템 구입`(`252:160233`) 카드.
///
/// 구매하면 구매자에게 기여 POP, 팬덤 Drop Pool 에 같은 수량의 일반 POP 이 생긴다
/// (원장 B-84). 수치는 Admin 저장값이며 표본은 사본 문구다.
/// (정책 원장 B-84). 수치는 Admin 저장값이며 표본은 사본 문구다.
class ItemProduct {
const ItemProduct({
this.productId = '',
@@ -504,7 +504,7 @@ abstract interface class HomeRepository {
/// 보상)이 HOME 헤더에도 바로 반영된다.
Stream<int> watchPopBalance();
/// 성운(Stage0) 생성 — `fn-nebula-create`(원장 B-08 · B-09). 만든 팬덤 ID 와, 대표 팬덤이
/// 성운(Stage0) 생성 — `fn-nebula-create`(정책 원장 B-08 · B-09). 만든 팬덤 ID 와, 대표 팬덤이
/// 바뀌었는지(C-15 30일 대기 중이면 false — 성운만 생기고 대표는 그대로)를 돌려준다.
Future<({String fandomId, bool primaryChanged})> createNebula(String name);
@@ -158,7 +158,7 @@ class FirestoreHomeRepository implements HomeRepository {
.limit(30)
.get();
final blocksF = db.collection('users/$uid/blocks').get();
// Hero Gift Catalog(원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가
// Hero Gift Catalog(정책 원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가
// 결제 창을 열기 전에 막는다(`fn-iap-verify` 도 거부한다).
final itemsF = db
.collection('heroGiftProducts')
@@ -13,7 +13,7 @@ import '../../core/i18n/app_locale.dart';
/// 틀은 사본(이름 입력 → 성운 → 목표 영역 → 「성운 생성」)이고 숫자는 정책 원장이다.
/// B-10 — 성운은 **10명 이상 + 1,000 POP 이상**을 기본 목표 **7일** 안에 채우면 정식
/// 행성이 된다. 목표가 고정값이라 사본의 목표 슬라이더 자리에는 조작할 수 없는 고정
/// 표시를 둔다. 근거는 발주사 10/3 통합회신(원장 C-112) 「성운 생성은 10 명 + 1,000 POP +
/// 표시를 둔다. 근거는 발주사 10/3 통합회신(정책 원장 C-112) 「성운 생성은 10 명 + 1,000 POP +
/// 기본 목표 7 일, 사용자 목표 POP 조작 없음」 — Figma `516:87407` 목표 POP · 창립멤버 핸들러를
/// 이 회신이 대체한다. B-19 — 진화 참여자에게는
/// 기념 배지만 지급하므로 사본의 「1T 등급 · 거버넌스」 문장은 그 내용으로 바꾼다.
@@ -14,7 +14,7 @@ import '../../core/i18n/app_locale.dart';
/// 정책 원장 C-50 — 생성 때 고르는 것은 **최초 기본 모델의 형태와 컬러 두 가지**뿐이고,
/// 생성 뒤 사용자가 외형을 바꾸는 입구는 없다. 그래서 사본의 「오브젝트 색 선택」 줄과
/// 직접 색 고르기(스포이트) 칩은 두지 않는다. 사용자화(컬러 팔레트)를 빼는 근거는 발주사
/// 10/3 통합회신 #6(원장 C-115) 「사용자 자유 HEX/RGB 컬러피커는 적용하지 않음」 — Figma
/// 10/3 통합회신 #6(정책 원장 C-115) 「사용자 자유 HEX/RGB 컬러피커는 적용하지 않음」 — Figma
/// `2066:17910` 의 「사용자화」를 이 회신이 대체한다. 「오브젝트 색」 줄은 Q-A14 회신 대기.
///
/// 형태 칸 · 미리보기는 행성 형태 시트 그림이다([planetShapeImage] — 6번 칸만 자리표시, Q-A14).
@@ -13,7 +13,7 @@ import '../../../core/i18n/app_locale.dart';
/// 「아이템 선택」(ZAP 옵션 「아이템」 · `343:204868`).
///
/// 두 시트는 제목과 카드 아랫줄만 다르다 — 구입은 「보유 : N」 + 가격, 선택은 「보유 : N개」.
/// 구입은 스토어 결제 → `fn-iap-verify` 검증 → 구매자 기여 POP · 팬덤 Drop(원장 B-84)이고
/// 구입은 스토어 결제 → `fn-iap-verify` 검증 → 구매자 기여 POP · 팬덤 Drop(정책 원장 B-84)이고
/// 결과는 앱 전역 알림으로 온다([Store.messages]). 사본의 결제 시트(「앱 내 구입」)는 OS 결제
/// 창이라 앱이 그리지 않는다. 구매가 곧 발동이라 보유 재고가 없어 「선택」(사용)은 준비 중이다.
Future<void> showItemSheet(
+2 -2
View File
@@ -44,7 +44,7 @@ class QuestRows extends StatelessWidget {
///
/// 보조 줄은 카테고리(사본 「오늘의 출석체크」 · 「커뮤니티」)다. 「홈」 프레임에는 보상 숫자 ·
/// 진행 상태가 없어 그리지 않는다. 타임어택 남은 시간도 그리지 않는다 — 설명 프레임
/// `2097:16397` 데이터는 「어드민에서 등록된 퀘스트」뿐이고, FR-QST-06 · 원장 C-94 의 카운트다운
/// `2097:16397` 데이터는 「어드민에서 등록된 퀘스트」뿐이고, FR-QST-06 · 정책 원장 C-94 의 카운트다운
/// 표시 위치는 퀘스트 목록 · 상세다.
class QuestRow extends StatelessWidget {
const QuestRow({
@@ -98,7 +98,7 @@ class QuestRow extends StatelessWidget {
),
),
const SizedBox(height: 4),
// 진행중 탭 카드와 같은 일시 정지 표기(원장 C-83).
// 진행중 탭 카드와 같은 일시 정지 표기(정책 원장 C-83).
_sub(questSubLine(quest.category ?? '', quest.pausedSession)),
],
),
+1 -1
View File
@@ -81,7 +81,7 @@ class InsideQuestion {
/// 답변이 채택되어 현상금이 지급된 상태.
final bool isSolved;
/// 채택 기한(원장 B-39 · 7일)이 지난 뒤의 환불 단계.
/// 채택 기한(정책 원장 B-39 · 7일)이 지난 뒤의 환불 단계.
final InsideRefund refund;
/// 채택을 받을 수 있는 상태 — 채택도 만료도 아니다.
+2 -2
View File
@@ -524,7 +524,7 @@ PaymentState paymentStateOf(String state) => switch (state) {
_ => PaymentState.paid,
};
/// Focus 구독 — 원장 **B-04** *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음*.
/// Focus 구독 — 정책 원장 **B-04** *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음*.
class FocusStatus {
const FocusStatus({required this.expiresAt, required this.claimedToday});
@@ -796,7 +796,7 @@ abstract interface class MyRepository {
/// Focus 구독 상태. 구독 중(만료 전)이 아니면 null — MY 에 카드를 그리지 않는다.
Future<FocusStatus?> focus();
/// Focus 오늘 몫 받기(원장 B-04). 받은 기여 POP 을 돌려준다. 구독이 아니면 E-PAY-03.
/// Focus 오늘 몫 받기(정책 원장 B-04). 받은 기여 POP 을 돌려준다. 구독이 아니면 E-PAY-03.
Future<int> claimFocusDaily();
Future<Map<PushTopic, bool>> pushSettings();
+2 -2
View File
@@ -102,7 +102,7 @@ class _MyScreenState extends State<MyScreen> {
}
}
/// Focus 오늘 몫 받기(원장 B-04). 지급은 서버가 하고 화면은 결과만 알린다.
/// Focus 오늘 몫 받기(정책 원장 B-04). 지급은 서버가 하고 화면은 결과만 알린다.
Future<void> _claimFocus() async {
if (_claiming) return;
setState(() => _claiming = true);
@@ -501,7 +501,7 @@ class _MyHeader extends StatelessWidget {
}
/// 활동 탭의 한 줄 — 글자 16/600 · 끝 chevron. 줄 높이 24 · 간격 24.
/// Focus 구독 혜택 카드 — 원장 B-04 *일 40 기여 POP · 당일 Claim · 미수령 누적 없음*.
/// Focus 구독 혜택 카드 — 정책 원장 B-04 *일 40 기여 POP · 당일 Claim · 미수령 누적 없음*.
///
/// Figma 에는 이 Claim 자리가 없다(퀘스트 완료 화면의 「광고 제거 보상 · 퀘스트 보상 × 2」
/// `518:88748` 은 B-04 가 대체한 과거 안). 반영 화면이 `MY` 로 정해져 있어 통계 카드 아래에 둔다.
+1 -1
View File
@@ -12,7 +12,7 @@ import '../../core/ui/bottom_inset.dart';
/// 명세 `549:22136` — *완료 디폴트 비활성화 · 변경 사항 있을 경우 활성화 ·*
/// ***중복된 닉네임일 경우 닉네임 필드 아래 헬프 텍스트로 오류 메시지 노출***
///
/// 중복 오류는 **별첨 B 「닉네임」(2~20자 · 중복 허용 · 내부 UID 식별, 원장 B-52)** 이 대체했다 —
/// 중복 오류는 **별첨 B 「닉네임」(2~20자 · 중복 허용 · 내부 UID 식별, 정책 원장 B-52)** 이 대체했다 —
/// 중복을 이유로 막지 않는다. 헬프 텍스트 자리는 길이 · 예약어 오류에 쓴다.
///
/// 사진은 시안대로 MY 의 카메라 배지 → [ProfilePhotoScreen] 에서 바꾼다.
@@ -117,7 +117,7 @@ class AppNotification {
/// 이클립스 항목의 단계 — `start` · `defended`.
final String phase;
/// 원장 C-78 단계 — 앱 안 Push 와 같은 표([pushTierOf]). 알림함은 이 단계로 시각 위계를 준다(v1.5 8장).
/// 정책 원장 C-78 단계 — 앱 안 Push 와 같은 표([pushTierOf]). 알림함은 이 단계로 시각 위계를 준다(v1.5 8장).
PushTier get tier => pushTierOf(
eventType ??
switch (kind) {
@@ -229,7 +229,7 @@ TextStyle _style(Color color, {double height = 1.3}) => TextStyle(
),
};
/// 원장 C-78 위계 — 위기 · 공동행동 · 성취 · 결과(v1.5 8장 「알림 우선순위 표현」). Figma 는 배지 획이 모두
/// 정책 원장 C-78 위계 — 위기 · 공동행동 · 성취 · 결과(v1.5 8장 「알림 우선순위 표현」). Figma 는 배지 획이 모두
/// #333333 이라 결과 · 그 밖은 그대로 두고, 위 세 단계만 획 색으로 올린다.
/// 색은 **임시값** — 자료에 위계별 색 정의가 없어 기존 토큰(위기 = 경고 적색 · 공동행동 = 강조 · 성취 = 금)을 쓴다.
Color _tierRing(PushTier tier) => switch (tier) {
+2 -2
View File
@@ -308,7 +308,7 @@ class PlanetDetail {
/// hide」 · `543:19721` 「(타인 행성) 신고/차단 · (내 행성) 편집」 — 편집은 C-50 으로 두지 않는다.
final bool isOwner;
/// 성운 → 정식 행성 전환 조건 — 서버와 같은 `config/policy`(원장 B-10 · `nebulaMinPop` · `nebulaMinMembers`).
/// 성운 → 정식 행성 전환 조건 — 서버와 같은 `config/policy`(정책 원장 B-10 · `nebulaMinPop` · `nebulaMinMembers`).
final int nebulaTargetPop;
final int nebulaTargetMembers;
@@ -319,7 +319,7 @@ class PlanetDetail {
/// 「가입중 / 가입하기」 알약이 이 값을 그린다(명세 §2.72 「가입 버튼 - 소속 상태」).
final bool joined;
/// 서버 FPI 집계 시각(`fandoms.fpiUpdatedAt`, 원장 B-15). 없으면 null.
/// 서버 FPI 집계 시각(`fandoms.fpiUpdatedAt`, 정책 원장 B-15). 없으면 null.
final DateTime? fpiUpdatedAt;
/// 개설자 표시명 · 사진. `fandoms` 문서에 아직 자리가 없어 서버 상세는 null 이고,
@@ -214,7 +214,7 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
);
/// Figma `543:19721` — 더보기: (타인 행성) 신고/차단 · (내 행성) 편집. 편집은 행성 사용자 커스텀이라
/// 두지 않는다(원장 C-50 「생성 후 사용자가 외형을 바꾸는 진입점 0건」). 내 행성에는 신고 · 차단도 없다.
/// 두지 않는다(정책 원장 C-50 「생성 후 사용자가 외형을 바꾸는 진입점 0건」). 내 행성에는 신고 · 차단도 없다.
void _showMore(BuildContext context, String name, {required bool mine}) =>
showActionSheet(context, [
// 팬덤 역사는 시안 밖 기능이라 남기되 중립색으로 둔다.
@@ -466,7 +466,7 @@ class _FpiTab extends StatelessWidget {
children: [
// 침식·방어 진행 — `fn-eclipse-aggregate` 의 집계 문서와 샤드를 구독한다.
// TOTAL FPI 카드 위에 두는 이유는 침식이 곧 이 카드의 값을 깎기 때문이다
// (원장 B-55 — FPI 감소는 Eclipse 가 담당한다).
// (정책 원장 B-55 — FPI 감소는 Eclipse 가 담당한다).
EclipseBanner(
repository: eclipse,
fandomId: detail.fandomId,
@@ -544,7 +544,7 @@ class _FpiTab extends StatelessWidget {
///
/// 「기여 현황」 카드(#1a1a1c r12 · 안쪽 12/16) — 성운 성장률 · 진행 막대 · 0 ~ 목표,
/// 「실시간 기여 랭킹」 — 1위 왕관 · 빈 원 80 · 「아직 기여자가 없어요」.
/// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`.
/// 목표는 정책 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`.
/// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다.
class _NebulaFpi extends StatelessWidget {
const _NebulaFpi({
@@ -23,7 +23,7 @@ typedef ShakePlayerEvent = ({bool playing, Duration position, bool ended});
/// 재생되는 동안의 흔들기를 **영상 시각**으로 찍어 박마다 Perfect · Good · Miss · Combo 를
/// 띄운다. 판정 창은 운영값(🟡 임시값 — 회신 대기). 마지막 박의 창이 닫히거나 영상이 끝나면
/// 판정표를 돌려준다. 중간에 나가면 null 이다 — 호출자는 넘긴 [judge] 의 판정 · 영상 시각을
/// 일시 정지 세션으로 남기고, 「이어하기」 때 그 판정표를 다시 넘겨 멈춘 자리부터 잇는다(원장 C-83).
/// 일시 정지 세션으로 남기고, 「이어하기」 때 그 판정표를 다시 넘겨 멈춘 자리부터 잇는다(정책 원장 C-83).
///
/// Figma 에 이 진행 화면은 없다(상세 `480:14506` 은 「진행중」 버튼까지). 공통 기준 5항 —
/// 기존 범위 기능의 정상 구현에 필요한 화면이다. 상세의 시각 문법(#141414 · #1a1a1c r24)을 따른다.
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:mobile_scanner/mobile_scanner.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
/// 위치 Quest QR 인증 — 현장에 붙은 QR 을 스캔해 그 값을 돌려준다(원장 C-03 · PRD-05 「QR 인증」).
/// 위치 Quest QR 인증 — 현장에 붙은 QR 을 스캔해 그 값을 돌려준다(정책 원장 C-03 · PRD-05 「QR 인증」).
///
/// 판정은 서버가 한다(생성 때 발급한 코드와 대조). 이 화면은 첫 번째로 읽힌 값으로 닫힐 뿐이다.
/// 🟡 Figma 에 스캔 화면이 없어 카메라 전체 화면 + 닫기 버튼만 둔다(해석 확인 요청 영역B Q-B-10).
+2 -2
View File
@@ -97,7 +97,7 @@ class _AdBonusDialog extends StatelessWidget {
child: Text(
bonusPop == null
? context.l10n.adBonusPlaceholder
// 원장 C-116 — 운영값 기반 「+N 기여 POP」(증분은 기여 POP · C-122).
// 정책 원장 C-116 — 운영값 기반 「+N 기여 POP」(증분은 기여 POP · C-122).
: '+${formatPop(bonusPop!)} ${context.l10n.popKindContribution}',
style: TextStyle(
fontSize: 36,
@@ -166,7 +166,7 @@ class _AdBonusDialog extends StatelessWidget {
}
}
/// Focus(광고 제거 구독 · 원장 B-04) 구입 버튼. 가격은 스토어가 준 현지 가격이다 —
/// Focus(광고 제거 구독 · 정책 원장 B-04) 구입 버튼. 가격은 스토어가 준 현지 가격이다 —
/// 시안의 「₩3,900/월」은 확정가(US$1.99)와 달라 쓰지 않는다(공통 기준 2항).
/// 스토어에 상품이 없으면 「스토어 상품 준비 중」으로 멈춘다.
class _FocusOffer extends StatefulWidget {
@@ -319,7 +319,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: _pickPlace,
),
// 추가 인증 수단(원장 C-03 「Location 의 추가 인증 수단」 · PRD-05 「GPS · 사진 · QR 인증」).
// 추가 인증 수단(정책 원장 C-03 「Location 의 추가 인증 수단」 · PRD-05 「GPS · 사진 · QR 인증」).
// GPS 는 항상 들어간다. 🟡 Figma 생성 화면에 이 줄이 없어 배치는 임시다(해석 확인 요청 영역B Q-B-10).
Text(context.l10n.questExtraVerify, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: AppSpace.s2),
+18 -18
View File
@@ -20,7 +20,7 @@ import 'quest_evidence.dart' show ShakeJudge, ShakeWindows;
/// 진행 상태 — 명세가 **다섯 가지**를 못박았다.
///
/// 명세 주석은 *「자세히」 · 「시작하기」 · 「진행중」 · 「보상받기」 · 「보상완료」* 이지만,
/// 버튼 문구는 시각 기준(원장 C-44)인 Figma 화면을 따른다 — 「퀘스트 시작하기」 ·
/// 버튼 문구는 시각 기준(정책 원장 C-44)인 Figma 화면을 따른다 — 「퀘스트 시작하기」 ·
/// 「보상받기」 · 「보상 완료」(`264:46867` · `373:433420`). 상세 `272:245274` 는 「보상 받기」로
/// 띄어 썼지만 홈 · 목록 사본 표기로 통일한다.
///
@@ -32,7 +32,7 @@ enum QuestStage {
notStarted,
inProgress,
/// 화면 안에서 진행하는 Quest(Music Shake)를 진행 화면 밖으로 나와 멈춘 상태 — 원장 C-83(10/3 통합회신
/// 화면 안에서 진행하는 Quest(Music Shake)를 진행 화면 밖으로 나와 멈춘 상태 — 정책 원장 C-83(10/3 통합회신
/// Q-OB-01 A): 진행 상태 값 「일시 정지」 · 버튼 「이어하기」. Walk · 출석은 이 상태가 되지 않는다.
paused,
completed,
@@ -72,11 +72,11 @@ enum QuestType {
/// 안에서만 참여를 받는다. 판정 시각은 서버 시각이다(`fn-quest-timeattack`).
timeAttack,
/// 스밍·YouTube — 무보상 기본 참여(원장 C-04 · B-25, 9/16 확정). POP 지급 없이 참여 기록과
/// 스밍·YouTube — 무보상 기본 참여(정책 원장 C-04 · B-25, 9/16 확정). POP 지급 없이 참여 기록과
/// FPI 원천만 남는다(`fn-quest-stream-participate`).
streaming,
/// 사용자 생성 Vote · SNS — 인증 자료 제출 → 생성자 · 운영자 검토(원장 C-08 · C-10).
/// 사용자 생성 Vote · SNS — 인증 자료 제출 → 생성자 · 운영자 검토(정책 원장 C-08 · C-10).
vote,
sns,
@@ -134,7 +134,7 @@ enum QuestType {
/// `quests.rewardPop` 을 보지 않는다. 그래서 출석만 운영값을 읽는다. 운영값이
/// 없으면 null — 숫자를 지어내지 않고 화면이 중립 문구를 쓴다.
///
/// 운영 걸음 Quest 는 `fn-quest-steps` 가 `config/policy.stepTiers` 구간표(원장 B-26 —
/// 운영 걸음 Quest 는 `fn-quest-steps` 가 `config/policy.stepTiers` 구간표(정책 원장 B-26 —
/// 1 천/5 천/1 만/1.5 만보 = 5/6/7/8)대로 지급하고 `quests.rewardPop` 을 보지 않는다. 카드의
/// 목표 칸이 오늘 다음 구간(`[cleared]` 번째)이므로 보상 칸도 **그 구간의 지급액**이다.
int? rewardFrom(
@@ -152,7 +152,7 @@ enum QuestType {
]..sort((a, b) => a.steps.compareTo(b.steps));
if (tiers.isNotEmpty) return tiers[cleared.clamp(0, tiers.length - 1)].pop;
}
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24 · 보상액 운영값). 서버
// Music Shake 는 BPM 판정 성공 시 일반 POP(정책 원장 B-24 · 보상액 운영값). 서버
// (`fn-quest-verify-music-shake`)가 퀘스트의 `rewardPop` 을, 없으면
// `config/policy.musicShake.pop` 을 기본액으로 쓴다. 운영값이 둘 다 없을 때만 중립 문구.
final shake = policy['musicShake'];
@@ -210,7 +210,7 @@ enum QuestAuthor {
user,
}
/// 퀘스트 분류 — `config/questCatalog.categories`(원장 C-01). 순서 · 노출은 Admin 운영값이다.
/// 퀘스트 분류 — `config/questCatalog.categories`(정책 원장 C-01). 순서 · 노출은 Admin 운영값이다.
class QuestCategory {
const QuestCategory({
required this.id,
@@ -359,7 +359,7 @@ bool questFull(QuestCard card) =>
card.capacity! > 0 &&
card.participants >= card.capacity!;
/// 진행 세션 — `users/{uid}/questProgress/{questId}.session`(`fn-quest-progress` 만 쓴다 · 원장 C-83).
/// 진행 세션 — `users/{uid}/questProgress/{questId}.session`(`fn-quest-progress` 만 쓴다 · 정책 원장 C-83).
///
/// **표시용 상태다.** 화면을 다시 열거나 앱을 다시 켜도 「진행중」 탭 · 「이어하기」 · 목록 「보상받기」가
/// 남게 한다. 지급 판정은 여전히 검증 함수가 증거로 한다.
@@ -403,7 +403,7 @@ class QuestSession {
final Map<String, int>? judgement;
}
/// 일시 정지 · 진행 세션이 바뀌었다 — 탭 화면 하단 캡슐(원장 C-83)이 다시 읽는다.
/// 일시 정지 · 진행 세션이 바뀌었다 — 탭 화면 하단 캡슐(정책 원장 C-83)이 다시 읽는다.
final questSessionSignal = ValueNotifier<int>(0);
/// 목록 카드 하나.
@@ -433,7 +433,7 @@ class QuestCard {
final String title;
final String categoryName;
/// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 원장 C-83. 없으면 null.
/// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 정책 원장 C-83. 없으면 null.
final QuestSession? session;
/// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션이 서버에 남아 있다.
@@ -467,10 +467,10 @@ class QuestCard {
/// 서버 경로는 null 이고, 화면은 분모 없이 참여 수만 적는다.
final int? capacity;
/// 운영 Quest 의 광고 배수 — 원장 **B-03** 기본 x2, 지정 Quest 는 `quests/{id}.adMultiplier`.
/// 운영 Quest 의 광고 배수 — 정책 원장 **B-03** 기본 x2, 지정 Quest 는 `quests/{id}.adMultiplier`.
final int adMultiplier;
/// 사용자 생성 Quest 의 광고 추가 보상 — 원장 **C-02** 고정 10 기여 POP(운영값).
/// 사용자 생성 Quest 의 광고 추가 보상 — 정책 원장 **C-02** 고정 10 기여 POP(운영값).
final int ugcAdPop;
/// 참여 마감 시각(`quests.activeTo`). 타임어택만 채운다 — 화면이 남은 시간을 센다.
@@ -566,7 +566,7 @@ class QuestDetail {
final QuestCard card;
/// 위치 Quest 의 인증 수단(`quests.verifyMethods` — `gps` · `photo` · `qr`, 원장 C-03). PRD-05
/// 위치 Quest 의 인증 수단(`quests.verifyMethods` — `gps` · `photo` · `qr`, 정책 원장 C-03). PRD-05
/// 「장소 상세에서 인증 방법 … 확인」. GPS 는 항상, 사진 · QR 은 생성 때 고른 추가 수단이다.
final List<String> verifyMethods;
@@ -601,7 +601,7 @@ class QuestDetail {
final List<QuestSubmission> reviews;
}
/// 위치 Quest 의 추가 인증 증거(원장 C-03 · PRD-05 「사진 인증 · QR 인증」). GPS 좌표는 저장소가 따로 잰다.
/// 위치 Quest 의 추가 인증 증거(정책 원장 C-03 · PRD-05 「사진 인증 · QR 인증」). GPS 좌표는 저장소가 따로 잰다.
class PlaceProof {
const PlaceProof({this.qr, this.photo});
@@ -630,7 +630,7 @@ enum QuestTab {
QuestTab.all => true,
// 명세 `518:88405` — *진행중 - 진행중 탭 / 진행완료 - 진행중 탭 / 보상완료 - 완료 탭*.
// 보상을 받기 전(진행완료)까지는 진행중 탭에 두고, 받은 뒤에 완료 탭으로 옮긴다.
// 일시 정지도 진행 중이다(원장 C-83 — 진행 상태 값에 「일시 정지」를 더함).
// 일시 정지도 진행 중이다(정책 원장 C-83 — 진행 상태 값에 「일시 정지」를 더함).
QuestTab.inProgress =>
card.stage == QuestStage.inProgress ||
card.stage == QuestStage.paused ||
@@ -658,7 +658,7 @@ class QuestDraft {
this.extraVerify = const [],
});
/// 위치 Quest 의 추가 인증 수단(`photo` · `qr`, 원장 C-03). GPS 는 서버가 항상 넣는다.
/// 위치 Quest 의 추가 인증 수단(`photo` · `qr`, 정책 원장 C-03). GPS 는 서버가 항상 넣는다.
final List<String> extraVerify;
/// 지도에 꽂은 핀(등록 장소가 아닐 때) — 서버가 그 좌표로 장소를 만든다.
@@ -739,7 +739,7 @@ abstract interface class QuestRepository {
QuestSession? session,
});
/// 가장 최근에 일시 정지한 Quest 1건 — 탭 화면 하단 캡슐(원장 C-83 · QA 3-34). 없으면 null.
/// 가장 최근에 일시 정지한 Quest 1건 — 탭 화면 하단 캡슐(정책 원장 C-83 · QA 3-34). 없으면 null.
Future<QuestCard?> pausedQuest();
Future<String> create(QuestDraft draft);
@@ -805,7 +805,7 @@ class SampleQuestRepository implements QuestRepository {
@override
LocationConsent get locationConsent => SampleLocationConsent.shared;
/// 원장 C-01 매핑 중 사용자 생성 초기값(C-02 A안)으로 열린 활동만.
/// 정책 원장 C-01 매핑 중 사용자 생성 초기값(C-02 A안)으로 열린 활동만.
static QuestCatalog get _catalog => QuestCatalog(
categories: [
QuestCategory(
@@ -76,7 +76,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 방금 끝낸 Music Shake 판의 판정 합계 — 결과 화면 · 결과 카드에 쓴다.
Map<String, int>? _shakeResult;
/// 진행 세션(원장 C-83) — 서버에서 읽은 값을 이 화면에서 멈추면 바로 갈아 끼운다(「이어하기」 시작점).
/// 진행 세션(정책 원장 C-83) — 서버에서 읽은 값을 이 화면에서 멈추면 바로 갈아 끼운다(「이어하기」 시작점).
QuestSession? _session;
/// [QuestDetailScreen.autoAct] 를 한 번만 쓴다.
@@ -125,7 +125,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
}
}
/// 진행 세션을 서버에 남긴다(원장 C-83). 실패해도 이 화면의 진행은 계속된다 — 표시용 상태다.
/// 진행 세션을 서버에 남긴다(정책 원장 C-83). 실패해도 이 화면의 진행은 계속된다 — 표시용 상태다.
Future<void> _persist(
String questId,
QuestStage next, [
@@ -267,7 +267,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
value:
'${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}',
),
// 일시 정지 — 멈춘 시점의 진행값을 그대로 둔다(원장 C-83 · 해석 확인 요청서 Q-OB-01 A 5절).
// 일시 정지 — 멈춘 시점의 진행값을 그대로 둔다(정책 원장 C-83 · 해석 확인 요청서 Q-OB-01 A 5절).
if (stage == QuestStage.paused && _session != null) ...[
const SizedBox(height: 8),
_Row(
@@ -306,7 +306,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
),
const SizedBox(height: 8),
],
// 인증 방법 — PRD-05 MVP 수용 기준 「장소 상세에서 인증 방법 · 보상 · 참여 현황 확인」 · 원장 C-03.
// 인증 방법 — PRD-05 MVP 수용 기준 「장소 상세에서 인증 방법 · 보상 · 참여 현황 확인」 · 정책 원장 C-03.
if (card.type == QuestType.place) ...[
_Row(
label: context.l10n.questVerifyMethods,
@@ -841,7 +841,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
}
/// Music Shake 한 판 — 공식 영상을 보며 흔드는 진행 화면을 띄운다(PRD-04).
/// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 **일시 정지**로 둔다(원장 C-83).
/// 끝까지 했으면 판정표를 남기고 「보상받기」로, 중간에 나왔으면 **일시 정지**로 둔다(정책 원장 C-83).
/// [resume] 이 있으면 멈춘 판정 · 영상 시각부터 잇는다(「이어하기」).
Future<void> _playShake(QuestDetail detail, {QuestSession? resume}) async {
final MusicShakeSetup setup;
@@ -937,7 +937,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
_stage = QuestStage.completed;
}
});
// 진행완료를 남긴다 — 목록 「보상받기」 · 진행중 탭이 다시 읽어도 같게(원장 C-83).
// 진행완료를 남긴다 — 목록 「보상받기」 · 진행중 탭이 다시 읽어도 같게(정책 원장 C-83).
if (reached) {
unawaited(
_persist(
@@ -1087,7 +1087,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
return;
}
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 B-03 · C-122) —
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(정책 원장 B-03 · C-122) —
// 광고가 실패해도 기본 보상은 그대로다.
// 일 한도를 다 쓴 사람에게는 모달을 띄우지 않는다(별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」) —
// 띄우면 광고를 끝까지 보고도 증분 0 이다.
@@ -1142,7 +1142,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 광고 시청 전 대기(`494:14332`)를 띄운 채 광고를 불러와 보여 준다.
///
/// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면
/// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 ·
/// 그 사실을 알리고 **기본 보상으로 넘어간다**(정책 원장 B-03 — 실패 시 기본 보상 지급 ·
/// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다.
/// 이번 보상에서 광고가 실제로 화면에 떴는가 — skip 으로 닫았을 때도 시청 후 애니메이션을 거친다.
bool _adShown = false;
+2 -2
View File
@@ -147,7 +147,7 @@ typedef ShakeLive = ({
ShakeHit? last,
});
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(정책 원장 B-24).
///
/// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고 첫 박은 `quests.firstBeatMs` 이며(판 길이 `totalSegments` 는 곡 전체를 덮도록 운영자가 정한다), 흔들기는 가장 가까운 박
@@ -163,7 +163,7 @@ class ShakeJudge {
List<double> hits = const [],
this.position = Duration.zero,
}) : assert(bpm > 0 && segments > 0) {
// 일시 정지에서 「이어하기」 — 멈추기 전 판정을 그대로 잇는다(원장 C-83).
// 일시 정지에서 「이어하기」 — 멈추기 전 판정을 그대로 잇는다(정책 원장 C-83).
for (var i = 0; i < hits.length && i < segments; i++) {
if (hits[i] >= 0) _best[i] = hits[i];
}
@@ -144,7 +144,7 @@ class _QuestListScreenState extends State<QuestListScreen> {
Future<void> _act(QuestCard card) async {
// 명세 `518:88405` *보상 받기 버튼 클릭 시 POP 수령* — 목록에서 바로 받는다. 일시 정지는 「이어하기」
// (원장 C-83). 판정 · 광고 모달 · 보상 완료 화면은 상세의 같은 경로를 쓴다.
// (정책 원장 C-83). 판정 · 광고 모달 · 보상 완료 화면은 상세의 같은 경로를 쓴다.
if (card.stage == QuestStage.completed || card.stage == QuestStage.paused) {
await _open(card, act: true);
return;
@@ -146,7 +146,7 @@ class FirestoreQuestRepository implements QuestRepository {
return cards;
}
/// 진행 세션이 있는 걸음 Quest 의 오늘 걸음 — Walk 는 화면 밖 걸음까지 **실제 상태**로 보인다(원장 C-62 · C-83).
/// 진행 세션이 있는 걸음 Quest 의 오늘 걸음 — Walk 는 화면 밖 걸음까지 **실제 상태**로 보인다(정책 원장 C-62 · C-83).
/// Health 를 한 번 읽고, 못 쓰면 이번 실행에서 센서로 센 값, 그것도 없으면 null(세션의 마지막 값).
Future<int?> _walkFor(Map<String, dynamic> quest, Map<String, dynamic> progress) async {
if (QuestType.parse(quest.strOr('type')) != QuestType.steps || progress['session'] is! Map) {
@@ -183,7 +183,7 @@ class FirestoreQuestRepository implements QuestRepository {
s != null &&
!s.fits(Duration(milliseconds: quest.intOr('firstBeatMs')), quest.intOr('totalSegments'));
/// 진행 세션(`questProgress.session` — `fn-quest-progress` 만 쓴다 · 원장 C-83).
/// 진행 세션(`questProgress.session` — `fn-quest-progress` 만 쓴다 · 정책 원장 C-83).
static QuestSession? sessionOf(Map<String, dynamic> progress) {
final raw = progress['session'];
if (raw is! Map) return null;
@@ -560,7 +560,7 @@ class FirestoreQuestRepository implements QuestRepository {
'description': description,
});
/// 진행중 · 일시 정지 · 진행완료 · 중단을 서버 진행 문서에 남긴다(`fn-quest-progress` · 원장 C-83).
/// 진행중 · 일시 정지 · 진행완료 · 중단을 서버 진행 문서에 남긴다(`fn-quest-progress` · 정책 원장 C-83).
///
/// 진행 문서는 규칙상 서버 전용이라 함수로만 쓴다. 보상 상태는 여전히 [verify] 가 지급에 성공한 뒤
/// 서버 카운터로 확정되고, 지급되면 서버가 세션을 지운다.
@@ -839,7 +839,7 @@ class FirestoreQuestRepository implements QuestRepository {
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
final run = _shakeRun;
// 끝낸 판을 서버 세션에 남겨 두었다면 그 판정표로 — 목록에서 바로 「보상받기」(원장 C-83).
// 끝낸 판을 서버 세션에 남겨 두었다면 그 판정표로 — 목록에서 바로 「보상받기」(정책 원장 C-83).
final saved = card.session?.state == QuestStage.completed ? card.session : null;
final judgement = run?.judgement() ?? saved?.judgement;
final segments = run?.segments ?? saved?.total ?? 0;
@@ -5,7 +5,7 @@ import '../quest_data.dart';
import '../quest_detail_screen.dart';
import 'quest_card_tile.dart' show pausedLabel;
/// 탭 화면(HOME · INSIDE · GALAXY · MY) 하단 탭 바로 위 한 줄 캡슐 — 원장 C-83(10/3 통합회신 Q-OB-01 A).
/// 탭 화면(HOME · INSIDE · GALAXY · MY) 하단 탭 바로 위 한 줄 캡슐 — 정책 원장 C-83(10/3 통합회신 Q-OB-01 A).
///
/// 일시 정지한 Quest 가 있을 때만 가장 최근 1건을 「일시 정지 · Quest명 · 43/60」으로 띄우고, 누르면 그
/// Quest 상세로 간다. Walk · 출석은 대상이 아니다(일시 정지가 되지 않는다). 새 탭 · 독립 화면이 아니다.
@@ -70,7 +70,7 @@ class QuestIconBox extends StatelessWidget {
_ => (bg: AppColors.onCosmic, fg: Colors.black),
};
/// 일시 정지 표기 — 「카테고리 · 일시 정지 · 43/60」. 카드와 탭 위 캡슐이 같이 쓴다(원장 C-83).
/// 일시 정지 표기 — 「카테고리 · 일시 정지 · 43/60」. 카드와 탭 위 캡슐이 같이 쓴다(정책 원장 C-83).
String pausedLabel(QuestCard card, {bool title = false}) =>
title ? _paused(card.title, card.session, titleLast: true)
: _paused(card.categoryName, card.session);
@@ -159,7 +159,7 @@ class QuestCardTile extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
// 진행 상태 값 「일시 정지」 · 멈춘 진행값(원장 C-83 — 기존 카드 영역 안).
// 진행 상태 값 「일시 정지」 · 멈춘 진행값(정책 원장 C-83 — 기존 카드 영역 안).
questCardSubLine(card),
style: const TextStyle(
fontSize: 14,
@@ -6,7 +6,7 @@ import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import 'translation_data.dart';
/// 참여형 번역 — 「번역 댓글」 UI (FR-I18N-02 · 원장 B-63 · 해석 확인 요청서 i18n-참여형번역 2.1).
/// 참여형 번역 — 「번역 댓글」 UI (FR-I18N-02 · 정책 원장 B-63 · 해석 확인 요청서 i18n-참여형번역 2.1).
///
/// Figma
/// - `540:19136` · `527:21198` 댓글 입력 영역 — 「번역 댓글 작성 버튼(지구본) · 클릭 시 키보드
@@ -6,7 +6,7 @@ import '../../core/data/backend.dart';
/// 참여형 번역 — 요구사항 FR-I18N-02 · 정책 원장 B-63 · 별첨 1 ⑧.
///
/// 데이터 관계(원장 B-63): 번역 등록 → 「번역함」 표시 → 다른 멤버 유효/무효 마킹 →
/// 데이터 관계(정책 원장 B-63): 번역 등록 → 「번역함」 표시 → 다른 멤버 유효/무효 마킹 →
/// 24시간 pending → `fn-translation-settle` 확정 → 일반 POP 5.
///
/// 화면에서는 댓글 영역의 「번역 댓글」이다(Figma `201:50554` · `540:19136` · 해석 확인
+1 -1
View File
@@ -134,7 +134,7 @@ dependencies:
# API)」 · 대조표 B-02). 2주차 선검증리포트 §2 가 채택한 13.3.1 그대로다. 못 쓰면 위 pedometer 로 센다(B-26).
# 최소 OS 하한 영향 없음(선검증 §3.5 에 이미 들어간 값): Android minSdk 26(앱 29) · iOS 14.0(앱 15.0).
health: ^13.3.1
# 위치 Quest QR 인증 — 현장 QR 스캔(원장 C-03 · PRD-05). iOS 는 Apple Vision, Android 는 ML Kit 번들.
# 위치 Quest QR 인증 — 현장 QR 스캔(정책 원장 C-03 · PRD-05). iOS 는 Apple Vision, Android 는 ML Kit 번들.
# 최소 OS 하한 영향 없음: Android minSdk 23(앱 29) · iOS 12.0(앱 15.0).
mobile_scanner: ^7.4.2
# 생성자 상세의 「현장에 붙일 인증 QR」 그리기. 순수 Dart 라 네이티브 하한이 없다.
+2 -2
View File
@@ -359,7 +359,7 @@ void main() {
expect(snapshot.joined, isTrue);
});
// 원장 B-08 · B-17 — 성운은 랭킹 모집단이 아니다. 끼면 정식 행성 번호가 밀린다.
// 정책 원장 B-08 · B-17 — 성운은 랭킹 모집단이 아니다. 끼면 정식 행성 번호가 밀린다.
test('Stage0 성운은 순위를 매기기 전에 빠지고 맨 바깥 궤도 자리에 선다', () async {
final db = await _seed();
await db.doc('fandoms/neb').set({
@@ -374,7 +374,7 @@ void main() {
expect(snapshot.planets.firstWhere((p) => p.id == 'neb').rankLabel, '-');
});
// 원장 B-17 — 최근 7일 유효 활동이 없는 정식 행성(서버 rankingEligible: false)도 순위 밖이다.
// 정책 원장 B-17 — 최근 7일 유효 활동이 없는 정식 행성(서버 rankingEligible: false)도 순위 밖이다.
test('최근 7일 활동이 없는 행성은 순위 없이 바깥 궤도에 서고 Ranking View 중심이 되지 않는다', () async {
final db = await _seed();
await db.doc('fandoms/idle').set({
+1 -1
View File
@@ -34,7 +34,7 @@ import 'package:flutter_test/flutter_test.dart';
const _width = 375.0;
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
/// Figma 사본의 표본 문구(BTS_Hong · hon******@gmail.com …). 숫자는 원장 체계(C-18)로 담는다.
/// Figma 사본의 표본 문구(BTS_Hong · hon******@gmail.com …). 숫자는 정책 원장 체계(C-18)로 담는다.
class _FigmaMy extends SampleMyRepository {
@override
Future<MyProfile> profile() async => const MyProfile(
+2 -2
View File
@@ -217,7 +217,7 @@ void main() {
});
});
group('Music Shake 보상 표시 (원장 B-24)', () {
group('Music Shake 보상 표시 (정책 원장 B-24)', () {
test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 musicShake.pop 을 기본액으로 적는다', () {
const policy = {'stepsPop': 50, 'musicShake': {'pop': 1}};
// 걸음 보상(stepsPop)을 빌려 쓰지 않는다.
@@ -228,7 +228,7 @@ void main() {
});
});
group('걸음 보상 표시 (원장 B-26)', () {
group('걸음 보상 표시 (정책 원장 B-26)', () {
const policy = {
'stepTiers': [
{'steps': 5000, 'pop': 6},
+2 -2
View File
@@ -492,7 +492,7 @@ void main() {
});
});
group('구독 공유 — 팬덤당 하나 (검수 D5)', () {
group('구독 공유 — 팬덤당 하나', () {
test('여러 화면이 같은 원본 하나를 나눠 쓴다', () async {
// 화면마다 snapshots() 를 열면 한 팬덤에 리스너가 화면 수만큼 붙는다.
var opened = 0;
@@ -592,7 +592,7 @@ void main() {
expect(LiveEventKind.of('무엇인가'), LiveEventKind.notice);
});
test('마감이 지난 이클립스는 만료로 읽는다 (검수 D3)', () {
test('마감이 지난 이클립스는 만료로 읽는다', () {
final now = DateTime(2026, 9, 14, 12);
expect(
eclipseFixture(endsAt: now.subtract(const Duration(seconds: 1)))
+2 -2
View File
@@ -270,7 +270,7 @@ void main() {
reason: '한 프레임에 순간이동하면 「행성 간 이동감」이 없다');
});
// 리뷰 M3 — HUD 위/아래 뒤집힘이 비행 중에 일어나면 카드가 행성을 가로질러
// HUD 위/아래 뒤집힘이 비행 중에 일어나면 카드가 행성을 가로질러
// 순간 이동한다. 선택 시점에 정한 방향을 전환이 끝날 때까지 붙들어야 한다.
//
// 표본 6행성은 어느 것도 화면 위쪽 끝에서 출발하지 않아 뒤집힘이 나지 않는다.
@@ -328,7 +328,7 @@ void main() {
expect(find.textContaining(RegExp(r'상위 \d+%')), findsNothing);
});
// 리뷰 M2 — 행성이 뷰포트 밖이면 HUD 를 아예 그리지 않는다. 카드만 가장자리에
// 행성이 뷰포트 밖이면 HUD 를 아예 그리지 않는다. 카드만 가장자리에
// clamp 해 두면 어느 행성을 가리키는지 알 수 없고 연결선은 화면 밖에 그려진다.
// 고른 행성은 카메라가 따라가므로(10/5) 확대·초점 이동으로도 화면 밖으로 밀리지 않는다.
testWidgets('확대해도 고른 행성은 가운데에 남고 HUD 도 남는다', (tester) async {
+2 -2
View File
@@ -169,7 +169,7 @@ void main() {
eclipse: NoEclipseRepository(),
));
expect(find.text('TOTAL FPI : 284,310'), findsOneWidget);
// 원장 B-15 검증 기준 — 화면에 마지막 갱신 시각 노출(서버 시각이 없으면 --:--).
// 정책 원장 B-15 검증 기준 — 화면에 마지막 갱신 시각 노출(서버 시각이 없으면 --:--).
expect(find.text('마지막 갱신 --:--'), findsOneWidget);
expect(find.textContaining('30초'), findsNothing);
// 개설자 이름은 서버에 없으면 그리지 않는다 — 시안 표본 이름을 박지 않는다.
@@ -226,7 +226,7 @@ void main() {
testWidgets('이클립스가 발동 중이면 침식·방어 진행을 띄운다', (tester) async {
// 스펙 B — 「집계 문서를 갤럭시·행성 상세 HUD 에서 실시간으로 표시한다」.
// 침식은 TOTAL FPI 를 깎는 경로이므로(원장 B-55) 그 카드 위에 온다.
// 침식은 TOTAL FPI 를 깎는 경로이므로(정책 원장 B-55) 그 카드 위에 온다.
await _pumpTall(tester, PlanetDetailScreen(
repository: const SamplePlanetRepository(),
eclipse: SampleEclipseRepository(),
+2 -2
View File
@@ -310,7 +310,7 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
await tester.pumpAndSettle();
// 별첨 B B-28 — 배수형 철회. 추가분은 고정형 운영값(C-02 초기 10 기여 POP).
// 원장 C-116 — 「+N 기여 POP」 표기.
// 정책 원장 C-116 — 「+N 기여 POP」 표기.
expect(find.text('+10 기여 POP'), findsOneWidget);
expect(find.textContaining('배'), findsNothing);
// 별첨1 ⑥ No-Force — 보지 않아도 기본 보상을 받는다.
@@ -580,7 +580,7 @@ void main() {
});
});
group('일시 정지 · 이어하기 · 목록 보상받기 (원장 C-83 · 명세 518:88405)', () {
group('일시 정지 · 이어하기 · 목록 보상받기 (정책 원장 C-83 · 명세 518:88405)', () {
testWidgets('목록 「보상받기」는 상세를 거쳐 바로 수령 흐름(보상 안내 모달)을 연다', (tester) async {
await pumpWide(tester, const QuestListScreen(repository: _sample));
await tester.pump();
+1 -1
View File
@@ -460,7 +460,7 @@ void main() {
await tester.enterText(
find.widgetWithText(TextField, '캠페인 설명을 작성해주세요.'), '스태프 응원',
);
// 원장 B-32 · B-59 — 목표는 입력이 아니라 상품 선택. C-14 — 권장 기간 자동 입력.
// 정책 원장 B-32 · B-59 — 목표는 입력이 아니라 상품 선택. C-14 — 권장 기간 자동 입력.
expect(find.widgetWithText(TextField, '목표 POP'), findsNothing);
await tester.tap(find.text('커피차 서포트'));
await tester.pump();
+4 -4
View File
@@ -52,7 +52,7 @@ void main() {
expect(clock.velocity, closeTo(GalaxyMotion.idleSpin, 1e-6));
});
// 리뷰 H1 — onInteractionUpdate 가 focalPointDelta.dx 를 무조건 넘기면
// onInteractionUpdate 가 focalPointDelta.dx 를 무조건 넘기면
// 세로 드래그·핀치 줌이 각속도를 덮어써 궤도가 선다.
test('세로 드래그·핀치 줌은 각속도를 바꾸지 않는다', () {
final dragged = GalaxyClock();
@@ -108,7 +108,7 @@ void main() {
);
});
// 리뷰 H2 — 기본 궤도 속도가 프로토타입의 1/4~1/5 이었다.
// 기본 궤도 속도가 프로토타입의 1/4~1/5 이었다.
// 프로토타입은 `time = Date.now()*0.0005` 에 `speed = 0.8/(rank+1)` 를 곱한다.
test('기본 궤도 각속도가 프로토타입과 같다 — 0.4/(rank+1) rad/s', () {
for (final rank in [2, 3, 6, 12]) {
@@ -136,7 +136,7 @@ void main() {
);
});
// 원장 B-21 — 기본은 내 Primary 중심, Ranking View 는 FPI 상위 중심.
// 정책 원장 B-21 — 기본은 내 Primary 중심, Ranking View 는 FPI 상위 중심.
test('Ranking View 는 FPI 1위(성운 제외)를 가운데 두고 내 행성은 순위 궤도를 돈다', () {
const snapshot = GalaxySnapshot(
myPop: 0,
@@ -173,7 +173,7 @@ void main() {
expect(maxDy, lessThan(maxDx));
});
// 리뷰 M4 — extent=900 에 간격 52 를 그대로 쓰면 8위부터 궤도가 맵 반쪽을
// extent=900 에 간격 52 를 그대로 쓰면 8위부터 궤도가 맵 반쪽을
// 넘어 Stack 에 잘리고, 반지름은 19위부터 음수가 된다.
test('행성이 많아도 전부 맵 안에 남는다 — 6·30·100개', () {
for (final n in [6, 30, 100]) {
+1 -1
View File
@@ -42,7 +42,7 @@ Widget _app(LocaleController c, Widget home) => ListenableBuilder(
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
group('지원 언어 (원장 B-76)', () {
group('지원 언어 (정책 원장 B-76)', () {
test('supportedLocales 가 정확히 ko · en · ja · zh-CN · es 다', () {
expect(appLocales.map(localeTag).toList(), [
'ko',
+1 -1
View File
@@ -14,7 +14,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
/// 결제 경계 — 별첨 1 ⑥ 「영수증 서버검증 통과」 · 원장 B-02 · B-72.
/// 결제 경계 — 별첨 1 ⑥ 「영수증 서버검증 통과」 · 정책 원장 B-02 · B-72.
///
/// 스토어와 서버는 대역이다. 보는 것은 두 가지다 — **서버가 받아들인 뒤에만 스토어에
/// 구매 완료를 알리는가**, 그리고 MY 결제 내역이 서버 문서를 화면 상태로 옳게 옮기는가.
+1 -1
View File
@@ -14,7 +14,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'eclipse_stubs.dart';
/// UX·FX v1.5 2차 약속분(10/8) — 진척현황 부록 B-3 1-2 · 4-2 · 4-3(퀘스트 완료 → 성공 햅틱 · 행성 반응),
/// 1-4 · 8-6(Push ↔ 라이브 띠 중복 제어 · 알림 위계 · Push 탭 실제 화면 이동), 원장 C-78 단계 배정.
/// 1-4 · 8-6(Push ↔ 라이브 띠 중복 제어 · 알림 위계 · Push 탭 실제 화면 이동), 정책 원장 C-78 단계 배정.
void main() {
group('C-78 단계 배정 — Push · 알림함 같은 표', () {
test('위기 · 공동행동 · 성취 · 결과 · 그 밖', () {
+1 -1
View File
@@ -630,7 +630,7 @@ service cloud.firestore {
});
});
group('진행 상태 저장 · 일시 정지 · 최신순 (원장 C-83 · 명세 518:88395)', () {
group('진행 상태 저장 · 일시 정지 · 최신순 (정책 원장 C-83 · 명세 518:88395)', () {
Future<FakeFirebaseFirestore> shakeSeed() async {
final db = await _seed();
await db.doc('quests/shake').set({
+1 -1
View File
@@ -15,7 +15,7 @@ import 'package:fancake/core/theme/app_theme.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
/// 참여형 번역 — FR-I18N-02 · 원장 B-63 · E-TRN-01.
/// 참여형 번역 — FR-I18N-02 · 정책 원장 B-63 · E-TRN-01.
/// 화면에서는 댓글 영역의 「번역 댓글」이다(Figma `201:50554` · `540:19136`).
void main() {
const target = 'questions/q1';