행성 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
+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,