행성 Level 1~100 수치표 적용 · 행성 컬러 마스터

This commit is contained in:
Sn-Kinos
2026-10-05 22:20:51 +09:00
parent cc0f2fb292
commit 000c058097
12 changed files with 424 additions and 64 deletions
+24 -1
View File
@@ -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<string[]> {
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(),
});
});
+32 -26
View File
@@ -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<Promotion[]> {
const db = getFirestore();
const stageDocs = await db.collection('planetStages').get();
const required = new Map<number, { pop: number; sfpi: number }>();
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<Promotion[]>
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;
+25 -4
View File
@@ -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<FpiAxis, number> | 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,
@@ -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<readonly [number, number]> = [
[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<Record<number, number>> = { 1: 1, 2: 21, 3: 41, 4: 61, 5: 81, 6: 100 };
/** Policy Params 「최근 7일 평균 ZAP 참여율 → 성장 가중치」. [하한 참여율, 배율]. */
export const ZAP_WEIGHT_BANDS: ReadonlyArray<readonly [number, number]> = [
[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<readonly [number, number]>;
stageLevelFrom: Readonly<Record<number, number>>;
zapWeightBands: ReadonlyArray<readonly [number, number]>;
/** 참여율 평균 기간(일) — 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<GrowthConfig> {
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<number, number> : 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),
};
}
@@ -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();
}
});
});
@@ -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<string, unknown>) => 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<typeof fnAdminPlanetEvolutionRun.run>[0]);