diff --git a/app/firebase/functions/src/account/account.ts b/app/firebase/functions/src/account/account.ts index caf4ef1..a9a09f2 100644 --- a/app/firebase/functions/src/account/account.ts +++ b/app/firebase/functions/src/account/account.ts @@ -51,8 +51,23 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => { /** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */ export const PLANET_SHAPES = ['shape1', 'shape2', 'shape3', 'shape4', 'shape5', 'shape6']; +/** + * 컬러 **기본 목록** — Figma 「행성 색 선택」 명세 `2066:17910`(흰 · 검 · 하늘 · 초록 · 노랑 · 주황 · 빨강 · 보라). + * 10/3 통합회신 #6 「최신 Figma 팬덤컬러를 기본으로 하고 발주사가 컬러 항목을 추가할 수 있는 구조, 특정 8색 고정 적용 + * 안 함」에 따라 실제 허용 목록은 컬러 마스터 `config/planetColors.colors[{key, hex, visible}]` 가 정한다 + * (Firestore 설계서 §2.17 · C-115). 마스터가 없을 때만 이 목록을 쓴다. + */ export const PLANET_COLORS = ['white', 'black', 'blue', 'green', 'gold', 'orange', 'red', 'purple']; +/** 생성 화면이 고를 수 있는(노출 중인) 컬러 키 — 마스터의 `visible !== false` 항목. */ +export async function planetColorKeys(): Promise { + const raw = (await getFirestore().doc('config/planetColors').get()).get('colors'); + if (!Array.isArray(raw)) return PLANET_COLORS; + const keys = raw.filter((c) => c && typeof c.key === 'string' && c.key && c.visible !== false) + .map((c) => c.key as string); + return keys.length > 0 ? keys : PLANET_COLORS; +} + /** * 운영값 기본 — 🟡 `config/policy` 가 덮는다. * - 성운 전환: 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」. @@ -344,7 +359,7 @@ export const fnPlanetCreate = onCall(async (req) => { const fandomId = str(req.data, 'fandomId'); const planetType = str(req.data, 'planetType'); const colorKey = str(req.data, 'colorKey'); - if (!PLANET_SHAPES.includes(planetType) || !PLANET_COLORS.includes(colorKey)) { + if (!PLANET_SHAPES.includes(planetType) || !(await planetColorKeys()).includes(colorKey)) { throw new HttpsError('invalid-argument', '행성 형태 또는 색이 올바르지 않습니다.'); } const db = getFirestore(); @@ -375,10 +390,18 @@ export const fnPlanetCreate = onCall(async (req) => { colorKey, createdAt: FieldValue.serverTimestamp(), }); + // 수치표 v1.4 「레벨 진행 기준점」 — 생성 시점 누적 POP 을 저장하고 이후 증가분부터 Lv.1→2 진행도에 + // 반영한다. 성운 기여 POP 은 growthPop 에 그대로 남는다(「소멸·초기화 없음」). + const birthPop = Number(fandom.growthPop ?? 0); tx.update(fandomRef, { stage: 1, + level: 1, + levelPop: 0, + growthPopAtBirth: birthPop, + levelCountedGrowthPop: birthPop, planetType, colorKey, + stageChangedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), }); }); diff --git a/app/firebase/functions/src/fandom/evolution.ts b/app/firebase/functions/src/fandom/evolution.ts index eb2ea0a..0443797 100644 --- a/app/firebase/functions/src/fandom/evolution.ts +++ b/app/firebase/functions/src/fandom/evolution.ts @@ -7,6 +7,7 @@ import { callerUid, requireRole, str } from '../core/callable.js'; import { ERR, fail } from '../core/errors.js'; import { sendFandomPush } from '../notify/push.js'; import { awardBadge } from '../account/passport.js'; +import { cumulativeFor, loadGrowthConfig, MAX_PLANET_LEVEL, planetLevelOf, stageOfLevel } from './planet_level.js'; /** v1.5 §2.1/§7: 확정 Stage 상승을 보존하고, 진화 주기 기여자에게 기념 Emblem 을 준다(상세정의 22장). */ export const fnPlanetEvolutionRecord = onDocumentUpdated( @@ -101,29 +102,21 @@ const FINAL_STAGE = 6; export interface Promotion { fandomId: string; fromStage: number; toStage: number } /** - * 정식 행성 Stage 승급 판정 — 원장 **B-11 · B-53 · B-54**. + * 정식 행성 Level · Stage 판정 — 수치표 v1.4(`planet_level.ts`) · 원장 **B-11 · B-53 · B-54**. * - * - **누적 Growth POP(`growthPop`) 과 sFPI(`sfpi`) 를 동시에** 다음 Stage 진입값 - * (`planetStages/{n}.requiredPop` · `requiredSfpi`, 설계서 §2.3 누계) 이상이어야 오른다. - * 하나만 채우면 그대로 대기한다. - * - 진입값이 누계라 초과분은 자연히 다음 Stage 진행도로 이월된다(B-54 Carry-over). 그래서 - * 한 번의 판정에서 여러 Stage 를 채웠으면 **채운 가장 높은 Stage 까지** 올린다. - * - 강등하지 않는다(B-53 검증 기준 「Stage 강등 0건」). Stage6 이 마지막이다. - * - 다음 Stage 마스터 문서가 없거나 값이 비면 그 Stage 에서 멈춘다 — 빈 값을 0 으로 읽어 - * 승급시키지 않는다. + * - 레벨업 POP 진행도(`levelPop`) = 생성 기준점 이후 Growth POP 증가분 × ZAP 참여도 가중치(`zapWeight`, + * 일일 마감이 정함). 가중치는 진행도에만 쓰고 `growthPop`(원장 누계)은 그대로 둔다. + * - Level = 진행도와 sFPI 가 **둘 다** 도달한 가장 높은 Level(동시 충족 · Carry-over). Stage = Level 구간. + * - 강등하지 않는다(B-53 「Stage 강등 0건」) — Level 도 내리지 않는다. Stage6(Lv.100)이 마지막이다. + * - 앱이 HOME · 상세에 그리도록 다음 Level · 다음 Stage 진입 누계를 함께 적는다. * - * 트랜잭션 안에서 다시 읽고 쓰므로 같은 날 두 번 돌아도 두 번째는 할 일이 없다(멱등). - * 기록(`stateHistory`)은 `fnPlanetEvolutionRecord` 가, 연출은 앱이 `stage` 상승을 보고 띄운다. + * 증가분을 반영한 뒤 `levelCountedGrowthPop` 을 옮기므로 하루에 여러 번 돌아도 두 번 세지 않는다(멱등). + * 기록(`stateHistory`)은 `fnPlanetEvolutionRecord` 가, 연출은 앱이 `stage` · `level` 상승을 보고 띄운다. */ export async function promoteStages(fandomIds?: string[]): Promise { const db = getFirestore(); - const stageDocs = await db.collection('planetStages').get(); - const required = new Map(); - for (const doc of stageDocs.docs) { - const pop = doc.get('requiredPop'); - const sfpi = doc.get('requiredSfpi'); - if (Number.isFinite(pop) && Number.isFinite(sfpi)) required.set(Number(doc.id), { pop, sfpi }); - } + const cfg = await loadGrowthConfig(); + const maxWeight = Math.max(1, ...cfg.zapWeightBands.map(([, w]) => w)); const refs = fandomIds ? fandomIds.map((id) => db.doc(`fandoms/${id}`)) @@ -135,16 +128,29 @@ export async function promoteStages(fandomIds?: string[]): Promise const snap = await tx.get(ref); const from = snap.get('stage'); if (!Number.isInteger(from) || from < 1 || from >= FINAL_STAGE) return null; - const pop = Number(snap.get('growthPop') ?? 0); + const growthPop = Number(snap.get('growthPop') ?? 0); + // 기준점이 없는 기존 행성은 0 — 생성 시점을 알 수 없어 전체 누계를 진행도로 본다(이관 대상). + const counted = Number(snap.get('levelCountedGrowthPop') ?? snap.get('growthPopAtBirth') ?? 0); + const rawWeight = Number(snap.get('zapWeight') ?? 1); + const weight = Number.isFinite(rawWeight) ? Math.min(maxWeight, Math.max(1, rawWeight)) : 1; + const delta = growthPop - counted; + // 회수(reverseGrowth)로 줄어든 몫은 배율 없이 뺀다. + const levelPop = Math.max(0, Number(snap.get('levelPop') ?? 0) + (delta > 0 ? delta * weight : delta)); const sfpi = Number(snap.get('sfpi') ?? 0); - let to = from as number; - for (let next = to + 1; next <= FINAL_STAGE; next++) { - const req = required.get(next); - if (!req || pop < req.pop || sfpi < req.sfpi) break; - to = next; - } + const prevLevel = Number.isInteger(snap.get('level')) ? snap.get('level') as number : 1; + const level = Math.max(prevLevel, planetLevelOf(levelPop, sfpi, cfg.steps)); + const to = Math.min(FINAL_STAGE, Math.max(from as number, stageOfLevel(level, cfg.stageLevelFrom))); + const [nextLevelPop, nextLevelSfpi] = cumulativeFor(level + 1, cfg.steps); + const nextStageFrom = cfg.stageLevelFrom[to + 1]; + const [nextStagePop, nextStageSfpi] = nextStageFrom ? cumulativeFor(nextStageFrom, cfg.steps) : [null, null]; + tx.update(ref, { + level, levelPop, levelCountedGrowthPop: growthPop, + nextLevelPop: level < MAX_PLANET_LEVEL ? nextLevelPop : null, + nextLevelSfpi: level < MAX_PLANET_LEVEL ? nextLevelSfpi : null, + nextStagePop, nextStageSfpi, + ...(to !== from ? { stage: to, stageChangedAt: FieldValue.serverTimestamp() } : {}), + }); if (to === from) return null; - tx.update(ref, { stage: to, stageChangedAt: FieldValue.serverTimestamp() }); return { fandomId: ref.id, fromStage: from as number, toStage: to, name: snap.get('name') as string | undefined }; }); if (!result) continue; diff --git a/app/firebase/functions/src/fandom/fandom.ts b/app/firebase/functions/src/fandom/fandom.ts index fbbae44..5fb72f3 100644 --- a/app/firebase/functions/src/fandom/fandom.ts +++ b/app/firebase/functions/src/fandom/fandom.ts @@ -14,6 +14,7 @@ import { txIdOf } from '../core/ids.js'; import { dayKey } from '../core/time.js'; import { notifyEclipseWarning } from '../notify/push.js'; import { promoteStages } from './evolution.js'; +import { loadGrowthConfig, zapWeightOf } from './planet_level.js'; import { PUBLIC_NAME_FALLBACK, publicNameOf } from '../core/names.js'; /** @@ -881,6 +882,7 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f const from = Timestamp.fromDate(new Date(`${day}T00:00:00+09:00`)); const to = Timestamp.fromMillis(from.toMillis() + 24 * 60 * 60 * 1000); const fandoms = await db.collection('fandoms').select('fpiAxes', 'fpiScore').get(); + const growth = await loadGrowthConfig(); for (const fandom of fandoms.docs) { const fpiAxes = (fandom.get('fpiAxes') as Record | undefined) @@ -891,10 +893,23 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f // 사용자 문서(`fandomDailyStats/{day}/users/{uid}`)를 함께 써서 개수만 세도록 옮긴다. const events = await fandom.ref.collection('fpiEvents') .where('occurredAt', '>=', from).where('occurredAt', '<', to).get(); - const users = new Set(events.docs - .filter((e) => e.get('verified') === true && typeof e.get('uid') === 'string' - && e.get('dauEligible') !== false) + const active = (e: FirebaseFirestore.QueryDocumentSnapshot) => e.get('verified') === true && + typeof e.get('uid') === 'string' && e.get('dauEligible') !== false; + const users = new Set(events.docs.filter(active).map((e) => e.get('uid') as string)); + // 수치표 v1.4 「ZAP 참여자 집계 — 일 1회 이상 유효 ZAP 한 고유 사용자」 ÷ 「최근 30일 Active 팬덤 사용자」. + // 🟡 Active 의 정의는 자료에 없다(해석 확인 요청 영역A Q-A03) — DAU 와 같은 「서버 인정 활동 1건 이상」. + // ponytail: 30일 이벤트 전수 조회. 팬덤이 커지면 일별 고유 사용자 문서로 옮긴다. + const zapUsers = new Set(events.docs.filter((e) => active(e) && e.get('activity') === 'zap') .map((e) => e.get('uid') as string)); + const activeSince = Timestamp.fromMillis(to.toMillis() - growth.activeDays * 86_400_000); + const activeUsers = new Set((await fandom.ref.collection('fpiEvents') + .where('occurredAt', '>=', activeSince).where('occurredAt', '<', to).select('uid', 'verified', 'dauEligible').get()) + .docs.filter(active).map((e) => e.get('uid') as string)); + const zapRate = activeUsers.size > 0 ? zapUsers.size / activeUsers.size : 0; + const recent = await fandom.ref.collection('fandomDailyStats') + .where('day', '<', day).orderBy('day', 'desc').limit(growth.zapRateDays - 1).get(); + const rates = [zapRate, ...recent.docs.map((d) => Number(d.get('zapRate') ?? 0))]; + const zapRateAvg = rates.reduce((a, b) => a + b, 0) / rates.length; // 스냅샷과 누적값은 함께 확정한다. 중간 실패가 하루치를 영구 누락시키지 않는다. await db.runTransaction(async (tx) => { @@ -903,8 +918,14 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f const snapshot = await tx.get(snapshotRef); // DAU 는 재실행에도 같은 값이다(같은 하루의 같은 이벤트). 덮어써도 안전하다. tx.set(statsRef, { - day, dau: users.size, source: 'fpiEvents', computedAt: FieldValue.serverTimestamp(), + day, dau: users.size, source: 'fpiEvents', + zapUsers: zapUsers.size, activeUsers: activeUsers.size, zapRate, + computedAt: FieldValue.serverTimestamp(), }); + // 성장 가중치 — 최근 [zapRateDays]일 평균 참여율의 구간 배율. 레벨업 POP 진행도에만 쓴다(promoteStages). + tx.set(fandom.ref, { + zapRate7: zapRateAvg, zapWeight: zapWeightOf(zapRateAvg, growth.zapWeightBands), + }, { merge: true }); if (snapshot.exists) return; tx.create(snapshotRef, { fpiAxes, diff --git a/app/firebase/functions/src/fandom/planet_level.ts b/app/firebase/functions/src/fandom/planet_level.ts new file mode 100644 index 0000000..74a4384 --- /dev/null +++ b/app/firebase/functions/src/fandom/planet_level.ts @@ -0,0 +1,122 @@ +import { getFirestore } from 'firebase-admin/firestore'; + +/** + * Planet Level Lv.1~100 · 레벨 진행 기준점 · ZAP 참여도 성장 가중치 — + * 발주사 **Q2 행성성장 레벨별 수치표 v1.4**(2026-08-25) 그대로. + * + * - 「Level Detail」 Lv.n → n+1 필요 POP · sFPI(99행). **둘 다** 채워야 오르고(「Level-up 조건 POP + sFPI 동시 + * 충족」), 초과분은 다음 Level 로 이월(「Carry-over 적용」) — 누계로 판정하면 둘이 자연히 성립한다. + * - 「Stage Summary」 Level 구간 — 시작 Lv.1~20 · 성장 21~40 · 함께 41~60 · 동행 61~80 · 빛 81~99 · 영원 100. + * 상세정의 3장의 Lv.1/10/25/40/55/75/100 과 다르며, 최신 서면인 수치표를 따른다(영역A 확인 통보 N-A01). + * - 「Policy Params」 레벨 진행 기준점 — 행성 생성 시점 누적 POP 을 저장하고 이후 증가분부터 진행도에 반영. + * - 「Policy Params」 ZAP 참여도 가중치 ×1.00~×1.50(7구간) — **레벨업 POP 진행도에만** 적용, 원장 · 잔액 · + * 누적 기여 POP · sFPI 에는 적용하지 않는다. + * - 「Admin 조정 가능」 — `planetLevels/{n}`(n → n+1 필요값) · `config/planetGrowth` 가 덮는다. + */ + +/** Level Detail — index i 는 Lv.(i+1) → Lv.(i+2) 의 [필요 POP, 필요 sFPI]. */ +export const PLANET_LEVEL_STEPS: ReadonlyArray = [ + [3000, 610], [3250, 630], [3500, 650], [3750, 670], [4000, 690], [4250, 710], [4500, 730], [4750, 750], + [5000, 770], [5250, 790], [5500, 810], [5750, 830], [6000, 850], [6250, 870], [6500, 890], [6750, 910], + [7000, 930], [7250, 950], [7750, 960], [10500, 1575], [11500, 1725], [12500, 1875], [13500, 2025], + [14500, 2175], [15500, 2325], [16500, 2475], [17500, 2625], [18500, 2775], [19500, 2925], [20500, 3075], + [21500, 3225], [22500, 3375], [23500, 3525], [24500, 3675], [25500, 3825], [26500, 3975], [27500, 4125], + [28500, 4275], [29500, 4425], [37000, 6500], [41000, 7000], [45000, 7500], [49000, 8000], [53000, 8500], + [57000, 9000], [61000, 9500], [65000, 10000], [69000, 10500], [73000, 11000], [77000, 11500], + [81000, 12000], [85000, 12500], [89000, 13000], [93000, 13500], [97000, 14000], [101000, 14500], + [105000, 15000], [109000, 15500], [113000, 16000], [210000, 31500], [230000, 34500], [250000, 37500], + [270000, 40500], [290000, 43500], [310000, 46500], [330000, 49500], [350000, 52500], [370000, 55500], + [390000, 58500], [410000, 61500], [430000, 64500], [450000, 67500], [470000, 70500], [490000, 73500], + [510000, 76500], [530000, 79500], [550000, 82500], [570000, 85500], [590000, 88500], [1050000, 157500], + [1150000, 172500], [1250000, 187500], [1350000, 202500], [1450000, 217500], [1550000, 232500], + [1650000, 247500], [1750000, 262500], [1850000, 277500], [1950000, 292500], [2050000, 307500], + [2150000, 322500], [2250000, 337500], [2350000, 352500], [2450000, 367500], [2550000, 382500], + [2650000, 397500], [2750000, 412500], [2850000, 427500], [2950000, 442500], +]; + +export const MAX_PLANET_LEVEL = 100; + +/** Stage Summary 의 Level 구간 시작 — Stage n 은 Lv.[n] 부터. */ +export const STAGE_LEVEL_FROM: Readonly> = { 1: 1, 2: 21, 3: 41, 4: 61, 5: 81, 6: 100 }; + +/** Policy Params 「최근 7일 평균 ZAP 참여율 → 성장 가중치」. [하한 참여율, 배율]. */ +export const ZAP_WEIGHT_BANDS: ReadonlyArray = [ + [0, 1.0], [0.05, 1.05], [0.10, 1.10], [0.20, 1.20], [0.30, 1.30], [0.40, 1.40], [0.50, 1.50], +]; + +export interface GrowthConfig { + steps: ReadonlyArray; + stageLevelFrom: Readonly>; + zapWeightBands: ReadonlyArray; + /** 참여율 평균 기간(일) — Policy Params 「최근 7일 · Admin 조정」. */ + zapRateDays: number; + /** 분모 Active 기간(일) — Policy Params 「최근 30일 · Admin 조정」. */ + activeDays: number; +} + +/** 누계로 본 Level — 레벨업 POP 진행도와 sFPI 가 **둘 다** 도달한 가장 높은 Level. */ +export function planetLevelOf(levelPop: number, sfpi: number, steps = PLANET_LEVEL_STEPS): number { + let level = 1; + let pop = 0; + let s = 0; + for (const [needPop, needSfpi] of steps) { + pop += needPop; + s += needSfpi; + if (levelPop < pop || sfpi < s) break; + level += 1; + } + return Math.min(level, MAX_PLANET_LEVEL); +} + +/** Lv.[level] 에 **도달하는** 누계 [POP, sFPI]. Lv.1 은 [0, 0]. */ +export function cumulativeFor(level: number, steps = PLANET_LEVEL_STEPS): [number, number] { + let pop = 0; + let sfpi = 0; + for (let i = 0; i < Math.min(level - 1, steps.length); i++) { + pop += steps[i]![0]; + sfpi += steps[i]![1]; + } + return [pop, sfpi]; +} + +export function stageOfLevel(level: number, from = STAGE_LEVEL_FROM): number { + let stage = 1; + for (const [s, lv] of Object.entries(from)) { + if (level >= lv && Number(s) > stage) stage = Number(s); + } + return stage; +} + +export function zapWeightOf(rate: number, bands = ZAP_WEIGHT_BANDS): number { + let weight = 1; + for (const [min, w] of bands) if (rate >= min) weight = w; + return weight; +} + +export async function loadGrowthConfig(): Promise { + const db = getFirestore(); + const [levels, config] = await Promise.all([ + db.collection('planetLevels').get(), db.doc('config/planetGrowth').get(), + ]); + const steps = PLANET_LEVEL_STEPS.map((step) => [...step] as [number, number]); + for (const doc of levels.docs) { + const n = Number(doc.id); + const pop = doc.get('requiredPop'); + const sfpi = doc.get('requiredSfpi'); + if (Number.isInteger(n) && n >= 1 && n < MAX_PLANET_LEVEL && + Number.isFinite(pop) && pop >= 0 && Number.isFinite(sfpi) && sfpi >= 0) { + steps[n - 1] = [pop, sfpi]; + } + } + const raw = config.data() ?? {}; + const bands = Array.isArray(raw.zapWeightBands) && + raw.zapWeightBands.every((b: unknown) => Array.isArray(b) && b.length === 2 && b.every(Number.isFinite)) + ? raw.zapWeightBands as Array<[number, number]> : ZAP_WEIGHT_BANDS; + const stageLevelFrom = raw.stageLevelFrom && typeof raw.stageLevelFrom === 'object' + ? raw.stageLevelFrom as Record : STAGE_LEVEL_FROM; + const positive = (v: unknown, d: number) => (Number.isInteger(v) && (v as number) > 0 ? v as number : d); + return { + steps, stageLevelFrom, zapWeightBands: bands, + zapRateDays: positive(raw.zapRateDays, 7), activeDays: positive(raw.activeDays, 30), + }; +} diff --git a/app/firebase/functions/test/planet_create.test.ts b/app/firebase/functions/test/planet_create.test.ts index fd918c0..2466b03 100644 --- a/app/firebase/functions/test/planet_create.test.ts +++ b/app/firebase/functions/test/planet_create.test.ts @@ -274,4 +274,19 @@ describe('행성 탄생', () => { expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: '#123456' }))) .toBe('invalid-argument'); }); + + it('컬러는 마스터(config/planetColors)가 정한다 — 발주사가 더한 색은 받고 숨긴 색은 받지 않는다(10/3 #6)', async () => { + await db().doc('config/planetColors').set({ colors: [ + { key: 'gold', hex: '#FFC500', visible: false }, + { key: 'mint', hex: '#3EB489', visible: true }, + ] }); + try { + const id = await nebula(10, 1_000); + expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('invalid-argument'); + await call(fnPlanetCreate, { ...pick(id), colorKey: 'mint' }); + expect(await fandom(id)).toMatchObject({ stage: 1, colorKey: 'mint', level: 1, levelPop: 0 }); + } finally { + await db().doc('config/planetColors').delete(); + } + }); }); diff --git a/app/firebase/functions/test/stage_promotion.test.ts b/app/firebase/functions/test/stage_promotion.test.ts index f6c0e09..2e30f84 100644 --- a/app/firebase/functions/test/stage_promotion.test.ts +++ b/app/firebase/functions/test/stage_promotion.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest'; -import { getFirestore } from 'firebase-admin/firestore'; +import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin } from './support.js'; +import { cumulativeFor } from '../src/fandom/planet_level.js'; const push = vi.hoisted(() => vi.fn(async () => true)); vi.mock('../src/notify/push.js', () => ({ sendFandomPush: push, notifyEclipseWarning: vi.fn() })); @@ -8,22 +9,18 @@ vi.mock('../src/notify/push.js', () => ({ sendFandomPush: push, notifyEclipseWar const { fnAdminPlanetEvolutionRun, promoteStages } = await import('../src/fandom/evolution.js'); const { closeDay } = await import('../src/fandom/fandom.js'); -/** 설계서 §2.3 누계 — 발주사 수치표 v1.4 「Stage Summary」(seed-demo.py 와 같은 표). */ -const STAGES: Array<[number, number, number]> = [ - [1, 0, 0], [2, 100_000, 15_000], [3, 500_000, 75_000], [4, 2_000_000, 300_000], - [5, 10_000_000, 1_500_000], [6, 50_000_000, 7_500_000], -]; +/** + * 수치표 v1.4 — Stage 는 Level 구간(시작 Lv.1~20 · 성장 21~). 성장(Stage2) 진입 = Lv.21 누계. + * Level Detail Lv.1→20 합 100,000 / 15,000 + Lv.20→21 10,500 / 1,575. + */ +const [LV21_POP, LV21_SFPI] = cumulativeFor(21); const id = 'test-stage-promotion'; const db = () => getFirestore(); -const stage = async () => (await db().doc(`fandoms/${id}`).get()).get('stage'); +const doc = async () => (await db().doc(`fandoms/${id}`).get()); +const stage = async () => (await doc()).get('stage'); const set = (data: Record) => db().doc(`fandoms/${id}`).set({ name: '승급', ...data }); -beforeAll(async () => { - bootAdmin(); - for (const [n, pop, sfpi] of STAGES) { - await db().doc(`planetStages/${n}`).set({ stageNo: n, requiredPop: pop, requiredSfpi: sfpi }); - } -}); +beforeAll(() => { bootAdmin(); }); beforeEach(async () => { push.mockClear(); await db().recursiveDelete(db().doc(`fandoms/${id}`)); @@ -33,25 +30,59 @@ afterAll(async () => { await shutdownAdmin(); }); -it('조건 미달 · 한쪽만 충족이면 대기한다', async () => { - for (const [pop, sfpi] of [[99_999, 14_999], [5_000_000, 14_999], [99_999, 9_000_000]]) { +it('성장 진입 누계는 수치표 그대로 110,500 POP · 16,575 sFPI', () => { + expect([LV21_POP, LV21_SFPI]).toEqual([110_500, 16_575]); + expect(cumulativeFor(20)).toEqual([100_000, 15_000]); + expect(cumulativeFor(100)).toEqual([50_000_000, 7_500_000]); +}); + +it('조건 미달 · 한쪽만 충족이면 대기한다 — Level 은 둘 다 도달한 만큼', async () => { + for (const [pop, sfpi] of [[LV21_POP - 1, LV21_SFPI], [5_000_000, LV21_SFPI - 1], [LV21_POP - 1, 9_000_000]]) { await set({ stage: 1, growthPop: pop, sfpi }); expect(await promoteStages([id])).toEqual([]); expect(await stage()).toBe(1); + expect((await doc()).get('level')).toBe(20); } expect(push).not.toHaveBeenCalled(); }); it('둘 다 충족하면 오르고, 두 번 돌아도 한 번만 오른다', async () => { - await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 }); + await set({ stage: 1, growthPop: LV21_POP, sfpi: LV21_SFPI }); await Promise.all([promoteStages([id]), promoteStages([id])]); await promoteStages([id]); expect(await stage()).toBe(2); + const d = await doc(); + expect(d.get('level')).toBe(21); + // 증가분은 한 번만 진행도에 들어간다. + expect(d.get('levelPop')).toBe(LV21_POP); + expect(d.get('nextLevelPop')).toEqual(cumulativeFor(22)[0]); + expect(d.get('nextStagePop')).toEqual(cumulativeFor(41)[0]); expect(push).toHaveBeenCalledTimes(1); }); +it('생성 기준점 이후 증가분만 진행도에 쓴다(수치표 「레벨 진행 기준점」)', async () => { + await set({ stage: 1, growthPop: 50_000 + LV21_POP - 1, growthPopAtBirth: 50_000, sfpi: LV21_SFPI }); + await promoteStages([id]); + expect(await stage()).toBe(1); + expect((await doc()).get('levelPop')).toBe(LV21_POP - 1); + // 성운 기여 POP 은 누계에 그대로 남는다(「소멸·초기화 없음」). + expect((await doc()).get('growthPop')).toBe(50_000 + LV21_POP - 1); +}); + +it('ZAP 참여도 가중치는 레벨업 POP 진행도에만 곱하고 원장 누계는 그대로 둔다', async () => { + const raw = Math.ceil(LV21_POP / 1.5); + await set({ stage: 1, growthPop: raw, sfpi: LV21_SFPI, zapWeight: 1.5 }); + await promoteStages([id]); + expect(await stage()).toBe(2); + expect((await doc()).get('growthPop')).toBe(raw); + // 상한 ×1.5 를 넘는 값은 상한으로 본다. + await set({ stage: 1, growthPop: raw - 10, sfpi: LV21_SFPI, zapWeight: 9 }); + await promoteStages([id]); + expect(await stage()).toBe(1); +}); + it('여러 Stage 를 채웠으면 동시 충족한 가장 높은 Stage 까지 · 성운 · 최종 단계는 건드리지 않는다', async () => { - // POP 은 Stage5 까지, sFPI 는 Stage3 까지 → Stage3. + // POP 은 Stage5 까지, sFPI 는 Lv.41~60(Stage3) 까지 → Stage3. await set({ stage: 1, growthPop: 10_000_000, sfpi: 100_000 }); expect(await promoteStages([id])).toEqual([{ fandomId: id, fromStage: 1, toStage: 3 }]); await set({ stage: 6, growthPop: 1e12, sfpi: 1e12 }); @@ -62,16 +93,37 @@ it('여러 Stage 를 채웠으면 동시 충족한 가장 높은 Stage 까지 · }); it('일일 마감(sFPI 누적) 직후 판정한다 — 그날 누적으로 문턱을 넘으면 오른다', async () => { - await set({ stage: 1, growthPop: 100_000, sfpi: 14_000, fpiScore: 1_000 }); + await set({ stage: 1, growthPop: LV21_POP, sfpi: LV21_SFPI - 1_000, fpiScore: 1_000 }); await closeDay(new Date('2030-01-02T00:00:30+09:00')); expect(await stage()).toBe(2); await closeDay(new Date('2030-01-02T00:00:30+09:00')); - expect((await db().doc(`fandoms/${id}`).get()).get('sfpi')).toBe(15_000); + expect((await doc()).get('sfpi')).toBe(LV21_SFPI); expect(push).toHaveBeenCalledTimes(1); }); +it('일일 마감이 ZAP 참여율(ZAP 고유 사용자 ÷ 최근 30일 Active)로 성장 가중치를 정한다', async () => { + await set({ stage: 1, growthPop: 0, sfpi: 0, fpiScore: 0 }); + const at = (iso: string) => Timestamp.fromDate(new Date(iso)); + const ev = (key: string, uid: string, activity: string, iso: string) => + db().doc(`fandoms/${id}/fpiEvents/${key}`).set({ uid, activity, verified: true, occurredAt: at(iso) }); + // 30일 Active 4명, 마감일(2030-03-01) ZAP 2명(한 사람 2회) → 50% → ×1.50. + await ev('a', 'u1', 'zap', '2030-03-01T10:00:00+09:00'); + await ev('b', 'u1', 'zap', '2030-03-01T11:00:00+09:00'); + await ev('c', 'u2', 'zap', '2030-03-01T12:00:00+09:00'); + await ev('d', 'u3', 'steps', '2030-02-20T12:00:00+09:00'); + await ev('e', 'u4', 'attend', '2030-02-10T12:00:00+09:00'); + await ev('old', 'u5', 'attend', '2029-12-01T12:00:00+09:00'); + await closeDay(new Date('2030-03-02T00:00:30+09:00')); + const stats = await db().doc(`fandoms/${id}/fandomDailyStats/2030-03-01`).get(); + expect([stats.get('zapUsers'), stats.get('activeUsers'), stats.get('zapRate')]).toEqual([2, 4, 0.5]); + expect((await doc()).get('zapWeight')).toBe(1.5); + // 다음 날 ZAP 0 → 2일 평균 25% → ×1.20. + await closeDay(new Date('2030-03-03T00:00:30+09:00')); + expect((await doc()).get('zapWeight')).toBe(1.2); +}); + it('운영자 즉시 실행은 master 만 · 결과를 adminActionLogs 에 남긴다', async () => { - await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 }); + await set({ stage: 1, growthPop: LV21_POP, sfpi: LV21_SFPI }); const run = (role: string) => fnAdminPlanetEvolutionRun.run({ data: { fandomId: id }, auth: { uid: `evo-${role}`, token: { role } }, } as unknown as Parameters[0]); diff --git a/app/lib/core/ui/planet_25d.dart b/app/lib/core/ui/planet_25d.dart index de83636..b91c6d0 100644 --- a/app/lib/core/ui/planet_25d.dart +++ b/app/lib/core/ui/planet_25d.dart @@ -22,8 +22,41 @@ const planetColors = <(String, Color)>[ ('purple', Color(0xFFC300C8)), ]; +/// 컬러 마스터 `config/planetColors` 에서 읽은 **전체** 목록(숨긴 색 포함 — 이미 그 색인 행성을 그려야 한다). +/// 10/3 통합회신 #6 「발주사가 컬러 항목을 추가할 수 있는 구조 · 8색 고정 적용 안 함」. 읽기 전에는 [planetColors]. +List<(String, Color)> _planetColorMaster = planetColors; + +/// `config/planetColors` 문서 → (그리기용 전체, 생성 화면에 내놓을 노출 목록). 형식이 틀린 항목은 건너뛴다. +/// 읽을 수 있는 항목이 없으면 기본 목록([planetColors]). +({List<(String, Color)> all, List<(String, Color)> visible}) planetColorsFrom( + Map? doc, +) { + final all = <(String, Color)>[]; + final visible = <(String, Color)>[]; + final raw = doc?['colors']; + if (raw is List) { + for (final c in raw) { + if (c is! Map) continue; + final key = c['key']; + final hex = c['hex']; + final m = hex is String ? RegExp(r'^#?([0-9a-fA-F]{6})$').firstMatch(hex) : null; + if (key is! String || key.isEmpty || m == null) continue; + final entry = (key, Color(0xFF000000 | int.parse(m.group(1)!, radix: 16))); + all.add(entry); + if (c['visible'] != false) visible.add(entry); + } + } + return visible.isEmpty + ? (all: [...all, ...planetColors], visible: planetColors) + : (all: all, visible: visible); +} + +/// 마스터를 읽은 뒤 그리기 목록을 바꾼다. +void setPlanetColorMaster(List<(String, Color)> all) => _planetColorMaster = all; + /// 서버 `colorKey` → 칩 색. 모르는 키(서버 기본값 `violet` 포함)는 null — 원래 일러스트 그대로. Color? planetColorOf(String? key) => + _planetColorMaster.where((c) => c.$1 == key).firstOrNull?.$2 ?? planetColors.where((c) => c.$1 == key).firstOrNull?.$2; /// Figma 본체에 UX/FX v1.5 §2의 Stage별 레이어를 조합한다. diff --git a/app/lib/features/home/home_data.dart b/app/lib/features/home/home_data.dart index ceba187..05c1080 100644 --- a/app/lib/features/home/home_data.dart +++ b/app/lib/features/home/home_data.dart @@ -8,7 +8,9 @@ /// 정책값이 아니다 — 정책은 `정책 설계서 최종본 v1.0` 이 정한다. library; +import 'package:flutter/painting.dart' show Color; import '../../core/i18n/app_locale.dart'; +import '../../core/ui/planet_25d.dart' show planetColors; import '../quest/quest_data.dart' show QuestType; import '../inside/inside_data.dart' show InsideQuestion, SampleInsideRepository; @@ -414,6 +416,9 @@ abstract interface class HomeRepository { /// 성운 전환 목표 — 서버(`fn-nebula-create` · `fn-planet-create`)와 같은 `config/policy` /// 키(`nebulaMinPop` · `nebulaMinMembers` · `nebulaDays`). 읽지 못하면 [defaultNebulaTargets]. Future nebulaTargets(); + + /// 행성 생성 화면이 내놓을 컬러 — 컬러 마스터 `config/planetColors`(10/3 #6 · C-115). 읽지 못하면 Figma 기본 8색. + Future> planetColorOptions(); } /// 성운 전환 목표 — POP · 창립 인원 · 목표 기간(일). @@ -451,6 +456,9 @@ class SampleHomeRepository implements HomeRepository { @override Future nebulaTargets() async => defaultNebulaTargets; + @override + Future> planetColorOptions() async => planetColors; + @override Future<({String fandomId, bool primaryChanged})> createNebula( String name, diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index 5650a73..570dc8d 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -3,6 +3,8 @@ import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp; import '../../core/data/backend.dart'; import '../../core/data/invite_code.dart'; import '../../core/data/server_clock.dart'; +import '../../core/ui/planet_25d.dart' show planetColors, planetColorsFrom, setPlanetColorMaster; +import 'package:flutter/painting.dart' show Color; import 'home_data.dart'; import '../inside/inside_repository_firestore.dart' show insideQuestionFrom; import '../quest/quest_data.dart' show QuestType; @@ -66,6 +68,19 @@ class FirestoreHomeRepository implements HomeRepository { 'colorKey': colorKey, }); + @override + Future> planetColorOptions() async { + try { + final master = planetColorsFrom( + (await _backend.db.doc('config/planetColors').get()).data(), + ); + setPlanetColorMaster(master.all); + return master.visible; + } catch (_) { + return planetColors; + } + } + @override Future nebulaTargets() async { try { @@ -82,6 +97,8 @@ class FirestoreHomeRepository implements HomeRepository { final uid = _backend.requireUid; // 공유 링크 `?ref=` 용 — 기다리지 않는다. InviteCode.ensure(_backend); + // 컬러 마스터 — HOME · GALAXY 행성이 발주사가 더한 색도 그리도록 먼저 읽어 둔다. 기다리지 않는다. + planetColorOptions(); final db = _backend.db; final userSnap = await db.doc('users/$uid').get(); @@ -291,8 +308,15 @@ class FirestoreHomeRepository implements HomeRepository { int? validInt(dynamic value) => value is int && value >= 0 ? value : null; num? validNumber(dynamic value) => value is num && value.isFinite && value >= 0 ? value : null; - final current = validNumber(fandom['growthPop']); - final required = validInt(next?['requiredPop']); + // 정식 행성은 서버가 수치표 v1.4 대로 적은 레벨업 POP 진행도(생성 기준점 이후 · ZAP 가중치 반영)와 + // 다음 Stage 진입 누계를 쓴다. 아직 판정 전인 행성만 구 마스터(`planetStages`)로 그린다. + final leveled = stage != null && stage > 0 && fandom['levelPop'] is num; + final current = leveled + ? validNumber(fandom['levelPop']) + : validNumber(fandom['growthPop']); + final required = leveled + ? validInt(fandom['nextStagePop']) + : validInt(next?['requiredPop']); // 성운(Stage0)의 전환 목표는 서버와 같은 `config/policy` 키로 읽는다. final policy = stage == 0 ? (await _backend.db.doc('config/policy').get()).data() @@ -325,7 +349,9 @@ class FirestoreHomeRepository implements HomeRepository { // Q-PN-01 A안 — 탄생은 창립자만. isFounder: fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid, - requiredSfpi: validNumber(next?['requiredSfpi']), + requiredSfpi: leveled + ? validNumber(fandom['nextStageSfpi']) + : validNumber(next?['requiredSfpi']), nebulaGoalPassed: goalPassed, ); } diff --git a/app/lib/features/home/planet_create_screen.dart b/app/lib/features/home/planet_create_screen.dart index 6630b84..8630842 100644 --- a/app/lib/features/home/planet_create_screen.dart +++ b/app/lib/features/home/planet_create_screen.dart @@ -42,7 +42,8 @@ class PlanetCreateScreen extends StatefulWidget { 'shape6', ]; - /// 색 키와 칩 색 — 렌더링과 같은 표([planetColors]). + /// 기본 색 키와 칩 색(Figma `2066:17910`). 화면은 컬러 마스터(`config/planetColors`, 10/3 #6)를 + /// [HomeRepository.planetColorOptions] 로 읽어 덮는다 — 발주사가 더한 색도 고를 수 있다. static const colors = planetColors; @override @@ -53,6 +54,19 @@ class _PlanetCreateScreenState extends State { int _shape = 0; int _color = 0; bool _colorStep = false; + List<(String, Color)> _colors = PlanetCreateScreen.colors; + + @override + void initState() { + super.initState(); + widget.repository.planetColorOptions().then((options) { + if (!mounted || options.isEmpty) return; + setState(() { + _colors = options; + _color = _color.clamp(0, options.length - 1); + }); + }); + } static const _placeholder = AssetImage( 'assets/figma/planet-shape-placeholder.png', @@ -72,7 +86,7 @@ class _PlanetCreateScreenState extends State { child: _colorStep ? ColorFiltered( colorFilter: ColorFilter.mode( - PlanetCreateScreen.colors[_color].$2, + _colors[_color].$2, BlendMode.modulate, ), child: const Image(image: _placeholder), @@ -141,7 +155,7 @@ class _PlanetCreateScreenState extends State { ); /// 사본 `Frame 1984081340` — 28 원 · 간격 8 · 고른 칩 분홍 4 테두리, 나머지 흰 2. - Widget _colors() => Column( + Widget _colorPicker() => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(context.l10n.planetBgColor, style: _heading), @@ -150,19 +164,19 @@ class _PlanetCreateScreenState extends State { spacing: 8, runSpacing: 8, children: [ - for (var i = 0; i < PlanetCreateScreen.colors.length; i++) + for (var i = 0; i < _colors.length; i++) GestureDetector( onTap: () => setState(() => _color = i), child: Semantics( button: true, selected: _color == i, - label: PlanetCreateScreen.colors[i].$1, + label: _colors[i].$1, child: Container( width: 28, height: 28, decoration: BoxDecoration( shape: BoxShape.circle, - color: PlanetCreateScreen.colors[i].$2, + color: _colors[i].$2, border: Border.all( color: _color == i ? AppColors.cosmicAccent @@ -200,7 +214,7 @@ class _PlanetCreateScreenState extends State { children: [ _preview(), const SizedBox(height: AppSpace.s5), - if (_colorStep) _colors() else _shapes(), + if (_colorStep) _colorPicker() else _shapes(), ], ), ), @@ -257,7 +271,7 @@ class _PlanetCreateScreenState extends State { await widget.repository.createPlanet( fandomId: widget.fandomId, planetType: PlanetCreateScreen.shapes[_shape], - colorKey: PlanetCreateScreen.colors[_color].$1, + colorKey: _colors[_color].$1, ); if (mounted) Navigator.of(context).pop(widget.fandomId); } on BackendException catch (e) { diff --git a/app/test/backend_repository_test.dart b/app/test/backend_repository_test.dart index 4cc130c..59fd094 100644 --- a/app/test/backend_repository_test.dart +++ b/app/test/backend_repository_test.dart @@ -109,6 +109,18 @@ void main() { expect(values.current.popConditionLabel, 'POP 조건 미충족'); }); + test('서버가 Level 판정을 적은 행성은 레벨업 POP 진행도와 다음 Stage 진입 누계로 그린다 (수치표 v1.4)', () async { + final db = await _seed(); + // 원장 누계 growthPop 이 아니라 기준점 이후 · 가중치 반영 진행도(levelPop)를 쓴다. + await db.doc('fandoms/bts').update({ + 'level': 20, 'levelPop': 105000.5, 'nextStagePop': 110500, 'nextStageSfpi': 16575, + }); + final home = await FirestoreHomeRepository(_backend(db)).load(); + expect(home.growth.currentPop, 105000.5); + expect(home.growth.requiredPop, 110500); + expect(home.growth.requiredSfpi, 16575); + }); + test('홈과 상세는 같은 팬덤의 서버 Stage를 표시하고 개인 Stage를 섞지 않는다', () async { final db = await _seed(); await db.doc('fandoms/bts').update({'stage': 4, 'colorKey': 'gold'}); @@ -679,9 +691,15 @@ void main() { await db.doc('users/$_uid').set({ 'uid': _uid, 'fandomId': 'bts', 'nickname': '아미보이', 'totalContributedPop': 24800, 'userExp': 500000, 'userLevel': 31, - 'passportTier': 'GOLD', + // 사용자 문서의 단일 Tier 는 읽지 않는다 — Passport 는 팬덤별 문서다(10/3 #10). + 'passportTier': 'DIAMOND', }); await db.doc('fandoms/bts/members/$_uid').set({'contributedPop': 24800}); + await db.doc('users/$_uid/passports/bts').set({'tier': 'GOLD'}); + // 탈퇴한 팬덤의 Passport 도 보존 기록으로 보인다(9/21 5절). + await db.doc('fandoms/left').set({'name': '떠난팬덤'}); + await db.doc('users/$_uid/passports/left').set({'tier': 'DIAMOND', 'contributedPop': 700}); + await db.doc('users/$_uid/badges/activity_health').set({'kind': 'activity', 'axis': 'health', 'stage': 2}); final profile = await FirestoreMyRepository(_backend(db)).profile(); expect(profile.contributedPop, 24800); @@ -689,7 +707,13 @@ void main() { expect(profile.levelLabel, 'Lv.31 BEAM'); expect(profile.userExp, 500000); expect(profile.passportTier, PassportTier.gold); - expect(profile.planetTiers.single.contributedPop, 24800); + final bts = profile.planetTiers.singleWhere((t) => t.fandomId == 'bts'); + expect(bts.contributedPop, 24800); + expect(bts.isPrimary, isTrue); + final left = profile.planetTiers.singleWhere((t) => t.fandomId == 'left'); + expect([left.joined, left.passportTier, left.contributedPop, left.fandomName], + [false, PassportTier.diamond, 700, '떠난팬덤']); + expect(profile.badges.single.label, 'HEALTH · 2/7'); expect(profile.balancePop, 12540); }); diff --git a/app/test/planet_fx_test.dart b/app/test/planet_fx_test.dart index 9f6dde0..f939c4a 100644 --- a/app/test/planet_fx_test.dart +++ b/app/test/planet_fx_test.dart @@ -7,6 +7,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('컬러는 마스터(config/planetColors)가 정한다 — 추가 색은 그리고 숨긴 색은 고르지 못한다 (10/3 #6)', () { + final master = planetColorsFrom({'colors': [ + {'key': 'gold', 'hex': '#FFC500', 'visible': false}, + {'key': 'mint', 'hex': '3EB489'}, + {'key': 'bad', 'hex': 'not-a-color'}, + ]}); + expect(master.visible.map((c) => c.$1), ['mint']); + expect(master.all.map((c) => c.$1), ['gold', 'mint']); + setPlanetColorMaster(master.all); + addTearDown(() => setPlanetColorMaster(planetColors)); + expect(planetColorOf('mint'), const Color(0xFF3EB489)); + expect(planetColorOf('gold'), const Color(0xFFFFC500)); + // 마스터가 비면 Figma 기본 8색. + expect(planetColorsFrom(null).visible, planetColors); + }); + // C-50 — 생성 화면이 고른 colorKey 를 같은 팔레트로 본체에 입힌다. testWidgets('행성 본체는 서버 colorKey 의 생성 팔레트 색을 입는다', (tester) async { expect(identical(PlanetCreateScreen.colors, planetColors), isTrue);